Artifact e2a2bbc86fa8ac8ea152745a31adb0465e06cbfd9e5de08f92690c92edcc7606:
- File
repo_checkout.go
— part of check-in
[ad09ba3d97]
at
2026-07-19 18:27:06
on branch trunk
— fix: give Timeline its own result type so an Ancestry cursor cannot compile into it (#78)
* fix: give Timeline its own result type so an Ancestry cursor cannot compile into it
LogEntry (Ancestry's result type) carried a Cursor field that was always the zero value on Ancestry entries — structurally identical to a legitimate Timeline first-page call. Feeding it into TimelineOpts.After silently paginated from page one forever with no error; documenting the field as invalid on Ancestry entries did nothing to stop it, because the zero cursor cannot be told apart from a valid first-call state even in principle.
Split the shared type instead of adding a runtime guard: LogEntry loses Cursor entirely and remains Ancestry's result type, while a new TimelineEntry (embeds LogEntry, adds Cursor) becomes Timeline's result type. An Ancestry entry's cursor can no longer be obtained at all, so passing one to Timeline is now a compile error rather than a documented hazard. Applied the same split to the internal manifest.LogEntry / manifest.TimelineEntry pair Timeline and Ancestry are built on.
Breaking change; CHANGELOG documents the migration.
* doc: warn against hand-assembling TimelineEntry from an Ancestry LogEntry
Review found the one remaining escape hatch: TimelineEntry{LogEntry: someAncestryEntry} compiles with a zero-value Cursor, which Timeline reads as "start from newest" rather than an error. No library path produces that value — it requires deliberately embedding an Ancestry LogEntry into a hand-built TimelineEntry — but flag it at the type so whoever does that gets the warning where they're typing. (user: [email protected], size: 6883) [annotate] [blame] [check-ins using]
package libfossil import ( "fmt" "time" "github.com/danmestas/libfossil/internal/deck" "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/manifest" ) // CommitOpts configures a commit operation. type CommitOpts struct { // Files is the caller-supplied subset of the new commit's tree. When // ParentID is non-zero, Files is MERGED with the parent's tracked files: // any name in Files overrides the parent's entry, and any file tracked // at the parent but not in Files is carried forward into the new manifest. // This matches `fossil ci`'s full-tree semantics — supplying just the // changed files does not erase the rest of the tree. Files []FileToCommit Comment string User string Tags []TagSpec Time time.Time ParentID int64 // MergeParents lists additional parents for a merge commit. The resulting // manifest's P-card is [ParentID, MergeParents...]; ParentID is the primary // parent (branch tip being committed onto), and each MergeParent contributes // a secondary plink row (isprim=0). MergeParents []int64 Delta bool // PartialManifest, when true, skips the parent-file merge: the resulting // manifest contains only Files. This is the legacy pre-fix behavior and // effectively erases every file tracked at the parent that is not in // Files. Default false matches `fossil ci`. Set true only when the // caller intentionally wants omission-to-mean-deletion (e.g., tests for // the diff/deletion path); a dedicated deletion API may replace this // escape hatch later. PartialManifest bool } // FileToCommit describes a file to include in a commit. type FileToCommit struct { Name string Content []byte Perm string } // TagSpec describes a tag to attach to an artifact. type TagSpec struct { Name string Value string } // CheckoutOpts configures a checkout extraction. type CheckoutOpts struct { Dir string Force bool } // FileEntry describes a file in a manifest. type FileEntry struct { Name string UUID string Perm string } // StatusEntry describes a changed file in the working tree. type StatusEntry struct { Name string Change string } // Commit creates a new checkin manifest with the given files and returns // the RID (row ID) and UUID of the newly created artifact. When ParentID // is non-zero, files tracked at the parent but absent from opts.Files are // carried forward into the new manifest (full-tree semantics — see the // CommitOpts.Files doc). func (r *Repo) Commit(opts CommitOpts) (int64, string, error) { files := make([]manifest.File, len(opts.Files)) for i, f := range opts.Files { files[i] = manifest.File{ Name: f.Name, Content: f.Content, Perm: f.Perm, } } if opts.ParentID > 0 && !opts.PartialManifest { merged, err := manifest.MergeParentFiles(r.inner, fsltype.FslID(opts.ParentID), files) if err != nil { return 0, "", fmt.Errorf("libfossil: commit: %w", err) } files = merged } var tagCards []deck.TagCard for _, t := range opts.Tags { tagCards = append(tagCards, deck.TagCard{ UUID: "*", Type: deck.TagPropagating, Name: t.Name, Value: t.Value, }) } var mergeParents []fsltype.FslID if len(opts.MergeParents) > 0 { mergeParents = make([]fsltype.FslID, len(opts.MergeParents)) for i, mp := range opts.MergeParents { mergeParents[i] = fsltype.FslID(mp) } } copts := manifest.CheckinOpts{ Files: files, Comment: opts.Comment, User: opts.User, Parent: fsltype.FslID(opts.ParentID), MergeParents: mergeParents, Delta: opts.Delta, Time: opts.Time, Tags: tagCards, } rid, uuid, err := manifest.Checkin(r.inner, copts) if err != nil { return 0, "", fmt.Errorf("libfossil: commit: %w", err) } return int64(rid), uuid, nil } // Ancestry walks the primary-parent chain starting from opts.Start: it // emits that check-in, follows plink where isprim=1 to its parent, and // repeats until the chain ends or opts.Limit is reached. This only ever // sees first-parent ancestors of opts.Start — a merge's second parent, a // sibling branch head, or any check-in outside that one chain will never // appear. Use Timeline for a repository-wide view. func (r *Repo) Ancestry(opts LogOpts) ([]LogEntry, error) { entries, err := manifest.Log(r.inner, manifest.LogOpts{ Start: fsltype.FslID(opts.Start), Limit: opts.Limit, }) if err != nil { return nil, fmt.Errorf("libfossil: ancestry: %w", err) } result := make([]LogEntry, len(entries)) for i, e := range entries { result[i] = LogEntry{ RID: int64(e.RID), UUID: e.UUID, Comment: e.Comment, User: e.User, Time: e.Time, Kind: e.Kind, Parents: e.Parents, } } return result, nil } // Timeline enumerates the repository's events newest-first: every row of // the event table by default, or just the rows matching opts.Type when // set (canonical `fossil timeline`'s -t/--type default: no filter unless // one is given). Unlike Ancestry, this does not start from or require any // particular check-in — it is a repository-wide view, not a walk. // // Ordering is (mtime DESC, rid DESC), a total order with rid as a true // tie-break at exact mtime equality — a deliberate improvement over // canonical fossil, which orders by mtime DESC alone with no tie-break, // and paginates with a bare-timestamp web cursor carrying a one-second // slop, so rows at a page boundary there can repeat or be skipped. Do not // "fix" this back to canonical: it is intentional. To page through the // full result set, pass the last entry's Cursor back as the next call's // TimelineOpts.After — never construct a cursor from a TimelineEntry's // Time by hand; see Cursor's doc comment for why that round trip is lossy. func (r *Repo) Timeline(opts TimelineOpts) ([]TimelineEntry, error) { entries, err := manifest.Timeline(r.inner, manifest.TimelineOpts{ Type: opts.Type, After: opts.After, Limit: opts.Limit, }) if err != nil { return nil, fmt.Errorf("libfossil: timeline: %w", err) } result := make([]TimelineEntry, len(entries)) for i, e := range entries { result[i] = TimelineEntry{ LogEntry: LogEntry{ RID: int64(e.RID), UUID: e.UUID, Comment: e.Comment, User: e.User, Time: e.Time, Kind: e.Kind, Parents: e.Parents, }, Cursor: e.Cursor, } } return result, nil } // ListFiles returns the files in a manifest identified by blob row-id. func (r *Repo) ListFiles(rid int64) ([]FileEntry, error) { entries, err := manifest.ListFiles(r.inner, fsltype.FslID(rid)) if err != nil { return nil, fmt.Errorf("libfossil: list files: %w", err) } result := make([]FileEntry, len(entries)) for i, e := range entries { result[i] = FileEntry{ Name: e.Name, UUID: e.UUID, Perm: e.Perm, } } return result, nil }