Artifact b10f44bc3ffcdc02d03c564d72a45c2169fd11aad3a95de6501c28fea1af2369:
- File
internal/manifest/log.go
— part of check-in
[9564ccad55]
at
2026-07-19 15:25:18
on branch trunk
— fix: tolerate NULL event.user/comment in manifest.Log (#47)
Fossil's check-in manifests require only the D and Z cards; the U-card (user) and C-card (comment) are optional, and canonical fossil binds a missing U-card as SQL NULL on the event row. manifest.Log scanned both columns into plain strings, so any repository containing such a check-in failed the entire walk with "converting NULL to string is unsupported" and returned zero rows.
Scan event.user and event.comment through sql.NullString and use its zero-value String field, so a NULL column becomes "" without changing LogEntry's public shape - no nullable type leaks out, and no caller has to know NULL was ever possible. Non-NULL columns are unaffected.
repo timeline now renders an empty user as "?", matching canonical fossil's coalesce(euser,user,'?') TTY output; the substitution stays in the CLI's presentation layer, not the library.
Fixes #44 (user: [email protected], size: 2552) [annotate] [blame] [check-ins using]
package manifest import ( "database/sql" "fmt" "time" "github.com/danmestas/libfossil/db" libfossil "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/repo" ) type LogOpts struct { Start libfossil.FslID Limit int } type LogEntry struct { RID libfossil.FslID UUID string Comment string User string Time time.Time Parents []string } func Log(r *repo.Repo, opts LogOpts) ([]LogEntry, error) { if r == nil { panic("manifest.Log: r must not be nil") } if opts.Start <= 0 { return nil, fmt.Errorf("manifest.Log: invalid start rid %d", opts.Start) } var entries []LogEntry current := opts.Start for { if opts.Limit > 0 && len(entries) >= opts.Limit { break } // event.user and event.comment are nullable in fossil's schema: the // U-card and C-card are optional on a check-in manifest (the only // required cards are "D" and "Z"). A NULL here is valid data, not // malformed input, so scan through sql.NullString and let its zero // value ("") stand in for "no value recorded" — the same substitution // fossil itself makes when reading these columns. var uuid string var user, comment sql.NullString var mtimeScanned any err := r.DB().QueryRow( "SELECT b.uuid, e.user, e.comment, e.mtime FROM blob b JOIN event e ON e.objid=b.rid WHERE b.rid=?", current, ).Scan(&uuid, &user, &comment, &mtimeScanned) if err != nil { return nil, fmt.Errorf("manifest.Log: rid=%d: %w", current, err) } // mtime is a julianday float. modernc returns float64; // ncruces returns time.Time for DATETIME/TIMESTAMP columns. Handle both. mtime, ok := db.ScanJulianDay(mtimeScanned) if !ok { return nil, fmt.Errorf("manifest.Log: rid=%d: unexpected mtime type %T", current, mtimeScanned) } var parents []string rows, err := r.DB().Query( "SELECT b.uuid FROM plink p JOIN blob b ON b.rid=p.pid WHERE p.cid=? ORDER BY p.isprim DESC", current, ) if err == nil { for rows.Next() { var puuid string if err := rows.Scan(&puuid); err != nil { continue } parents = append(parents, puuid) } rows.Close() } entries = append(entries, LogEntry{ RID: current, UUID: uuid, Comment: comment.String, User: user.String, Time: libfossil.JulianToTime(mtime), Parents: parents, }) var parentRid int64 if err := r.DB().QueryRow( "SELECT pid FROM plink WHERE cid=? AND isprim=1", current, ).Scan(&parentRid); err != nil { break } current = libfossil.FslID(parentRid) } return entries, nil }