Skip to content
go-libfossil Artifact [b39720a3d5]
Login

Artifact [b39720a3d5]

Artifact b39720a3d57f54100557ebb05cc6df749b6fe129ba36df9e2ab77e27b4174f81:


package libfossil

import (
	"database/sql"
	"errors"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/danmestas/go-libfossil/db"
	"github.com/danmestas/go-libfossil/internal/annotate"
	"github.com/danmestas/go-libfossil/internal/content"
	"github.com/danmestas/go-libfossil/internal/diff"
	"github.com/danmestas/go-libfossil/internal/fsltype"
	"github.com/danmestas/go-libfossil/internal/manifest"
	"github.com/danmestas/go-libfossil/internal/repo"
)

// ErrFileNotFound is returned by ReadFile when the requested filePath is
// not tracked in the given checkin. Callers can match with errors.Is.
var ErrFileNotFound = errors.New("libfossil: file not found in checkin")

// LogOpts configures an Ancestry query: a first-parent walk starting from
// a specific check-in.
type LogOpts struct {
	Start int64
	Limit int
}

// TimelineOpts configures a Timeline query: a repository-wide enumeration
// of the event table.
type TimelineOpts struct {
	// Type restricts the enumeration to a single event kind. The zero
	// value means "all kinds" — the canonical `fossil timeline` default.
	Type EventKind
	// After, when valid, resumes enumeration immediately following this
	// cursor — the next page after whatever TimelineEntry produced it. The
	// zero Cursor means "start from the newest event". See Repo.Timeline's
	// doc comment for the pagination contract this forms, and Cursor's doc
	// comment for why it must come from a TimelineEntry rather than be
	// built by hand.
	After Cursor
	// Limit caps the number of entries returned. Zero or negative means
	// unbounded.
	Limit int
}

// LogEntry represents a single event, as returned by Ancestry. Parents is
// populated for Kind == EventKindCheckin and empty for every other kind —
// plink, which Parents is derived from, only relates check-in artifacts,
// so that is correct, not a gap.
//
// LogEntry carries no pagination cursor: Ancestry paginates by
// LogOpts.Start/Limit, not by cursor, so there would be nothing valid to
// put in one. See TimelineEntry, Timeline's result type, for the
// cursor-carrying counterpart — this split means an Ancestry entry's
// (necessarily invalid) cursor can no longer be obtained at all, let alone
// passed to Timeline by mistake.
type LogEntry struct {
	RID     int64
	UUID    string
	Comment string
	User    string
	Time    time.Time
	Kind    EventKind
	Parents []string
}

// TimelineEntry represents a single event as returned by Timeline. It
// extends LogEntry with Cursor, the pagination token for this row: pass it
// back as the next call's TimelineOpts.After to resume enumeration
// immediately after this entry. Cursor is always valid
// (Cursor.Valid() == true) on every TimelineEntry Timeline produces.
//
// TimelineEntry values should come from Timeline, not be hand-assembled.
// A TimelineEntry built by embedding a LogEntry from Ancestry carries a
// zero-value Cursor, which Timeline treats as "start from newest" rather
// than as an error.
type TimelineEntry struct {
	LogEntry
	Cursor Cursor
}

// DiffEntry describes a unified diff for a single file.
type DiffEntry struct {
	Name    string
	Unified string
}

// AnnotatedLine is a single line of blame/annotate output.
type AnnotatedLine struct {
	Text string
	UUID string
	User string
	Date time.Time
}

// AnnotateOpts configures an annotate operation.
type AnnotateOpts struct {
	FilePath string
	StartRID int64
}

// BisectSession holds state for a binary-search bisect operation.
type BisectSession struct {
	inner interface{}
}

// Annotate attributes each line of a file to the commit that last changed it.
func (r *Repo) Annotate(opts AnnotateOpts) ([]AnnotatedLine, error) {
	lines, err := annotate.Annotate(r.inner, annotate.Options{
		FilePath: opts.FilePath,
		StartRID: fsltype.FslID(opts.StartRID),
	})
	if err != nil {
		return nil, fmt.Errorf("libfossil: annotate: %w", err)
	}
	result := make([]AnnotatedLine, len(lines))
	for i, l := range lines {
		result[i] = AnnotatedLine{
			Text: l.Text,
			UUID: l.Version.UUID,
			User: l.Version.User,
			Date: l.Version.Date,
		}
	}
	return result, nil
}

// Diff returns the unified diff(s) between ridA and ridB.
//
// When filePath is non-empty, returns 0 or 1 entries for that single file:
// the file is treated as empty bytes on any side where it is absent, so
// additions and deletions render as pure insert/delete hunks. An empty
// slice is returned when both sides are byte-identical.
//
// When filePath is empty, returns one entry per file that changed between
// the two checkins (the union of files across both sides where the content
// UUID differs or the file exists on only one side). Entries are sorted by
// Name for deterministic ordering. An empty slice is returned when the two
// checkins have identical file sets and content.
//
// Whole-checkin enumeration is currently name-keyed: a rename with no
// content change surfaces as a delete of the old name plus an add of the
// new name, and permission-only changes are not reflected. Proper rename
// and perm-change detection (via the underlying mlink table) is tracked as
// a follow-up; per-file diff behaviour for an explicitly named file is
// unchanged.
func (r *Repo) Diff(ridA, ridB int64, filePath string) ([]DiffEntry, error) {
	if filePath == "" {
		return r.diffWholeCheckin(ridA, ridB)
	}
	return r.diffSingleFile(ridA, ridB, filePath)
}

// diffSingleFile produces the diff for a single named path between two
// checkins. Extracted from Diff so the whole-checkin path can reuse the
// same per-file rendering for each changed file in the union set.
func (r *Repo) diffSingleFile(ridA, ridB int64, filePath string) ([]DiffEntry, error) {
	a, err := blobAt(r, ridA, filePath)
	if err != nil {
		return nil, fmt.Errorf("libfossil: diff: checkin %d: %w", ridA, err)
	}
	b, err := blobAt(r, ridB, filePath)
	if err != nil {
		return nil, fmt.Errorf("libfossil: diff: checkin %d: %w", ridB, err)
	}
	unified := diff.Unified(a, b, diff.Options{
		ContextLines: 3,
		SrcName:      "a/" + filePath,
		DstName:      "b/" + filePath,
	})
	if unified == "" {
		return []DiffEntry{}, nil
	}
	return []DiffEntry{{Name: filePath, Unified: unified}}, nil
}

// diffWholeCheckin enumerates the union of files across the two checkins
// and emits one DiffEntry per file whose content UUID differs or that
// exists on only one side. Results are sorted by Name.
func (r *Repo) diffWholeCheckin(ridA, ridB int64) ([]DiffEntry, error) {
	filesA, err := manifest.ListFiles(r.inner, fsltype.FslID(ridA))
	if err != nil {
		return nil, fmt.Errorf("libfossil: diff: checkins %d..%d: list files at %d: %w", ridA, ridB, ridA, err)
	}
	filesB, err := manifest.ListFiles(r.inner, fsltype.FslID(ridB))
	if err != nil {
		return nil, fmt.Errorf("libfossil: diff: checkins %d..%d: list files at %d: %w", ridA, ridB, ridB, err)
	}

	uuidA := make(map[string]string, len(filesA))
	for _, f := range filesA {
		uuidA[f.Name] = f.UUID
	}
	uuidB := make(map[string]string, len(filesB))
	for _, f := range filesB {
		uuidB[f.Name] = f.UUID
	}

	changed := make(map[string]struct{}, len(uuidA)+len(uuidB))
	for name, ua := range uuidA {
		if uuidB[name] != ua {
			changed[name] = struct{}{}
		}
	}
	for name, ub := range uuidB {
		if uuidA[name] != ub {
			changed[name] = struct{}{}
		}
	}

	if len(changed) == 0 {
		return []DiffEntry{}, nil
	}

	names := make([]string, 0, len(changed))
	for name := range changed {
		names = append(names, name)
	}
	sort.Strings(names)

	out := make([]DiffEntry, 0, len(names))
	for _, name := range names {
		entries, err := r.diffSingleFile(ridA, ridB, name)
		if err != nil {
			return nil, err
		}
		// diffSingleFile returns an empty slice when both sides are byte-
		// identical. Identical bytes under a changed UUID are not expected
		// (UUID is content-addressed), but skip the entry rather than emit
		// an empty diff if it does happen.
		out = append(out, entries...)
	}
	return out, nil
}

// ReadFile returns the bytes of filePath as they existed in checkin rid.
// Returns ErrFileNotFound (wrapped) if the file is not tracked in that
// checkin. A file that exists but is empty returns ([]byte{}, nil).
func (r *Repo) ReadFile(rid int64, filePath string) ([]byte, error) {
	if filePath == "" {
		return nil, fmt.Errorf("libfossil: read file: filePath is required")
	}
	data, err := blobAt(r, rid, filePath)
	if err != nil {
		return nil, fmt.Errorf("libfossil: read file: checkin %d: %w", rid, err)
	}
	if data == nil {
		return nil, fmt.Errorf("libfossil: read file: %q in checkin %d: %w", filePath, rid, ErrFileNotFound)
	}
	return data, nil
}

// ResolveVersion resolves a symbolic version name to a repository artifact RID.
//
// It follows fossil's symbolic_name_to_rid (src/name.c) for the forms
// go-libfossil supports, in fossil's order:
//
//  1. "tip" — newest check-in by event.mtime. "" is treated as "tip".
//  2. "rid:N" — the literal RID N, when that artifact exists.
//  3. "tag:NAME" — the check-in carrying sym-NAME with the greatest
//     event.mtime (forces a tag lookup even for a hex NAME).
//  4. "root:BR" / "start:BR" / "merge-in:BR" — the origin, first check-in, or
//     most recent merge-in of branch BR (BR itself resolved recursively).
//  5. "NAME:TIMESTAMP" — the check-in carrying sym-NAME with the greatest
//     event.mtime at or before TIMESTAMP (UTC).
//  6. Artifact hash or 4–64 char hex prefix — unique match against blob.uuid,
//     tried before a plain tag; more than one match is ambiguous.
//  7. Plain symbolic name — the check-in carrying sym-NAME with the greatest
//     event.mtime (resolves "trunk" and branch names).
//  8. Trailing-punctuation retry — a name pasted from prose that ends in one
//     or two punctuation characters is retried without them.
//
// No match returns ErrVersionNotFound (wrapped); a hex prefix matching more
// than one artifact returns ErrAmbiguousVersion (wrapped).
func (r *Repo) ResolveVersion(name string) (int64, error) {
	// go-libfossil's contract treats "" as "tip"; fossil treats an empty
	// name as unresolvable.
	if name == "" {
		name = "tip"
	}
	rid, err := r.resolveSymbolic(name)
	if err != nil {
		return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
	}
	if rid <= 0 {
		return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, ErrVersionNotFound)
	}
	return rid, nil
}

// start_of_branch modes, fossil's eType argument (src/name.c).
const (
	branchOrigin  = 0 // root:   the check-in the branch sprouted from
	branchFirst   = 1 // start:  the first check-in on the branch
	branchMergeIn = 2 // merge-in: the most recent merge-in for the branch
)

// resolveSymbolic is ResolveVersion's engine: fossil's symbolic_name_to_rid,
// as an ordered sequence of attempts. It returns 0 when nothing matches and
// ErrAmbiguousVersion when a hex prefix matches more than one artifact.
func (r *Repo) resolveSymbolic(name string) (int64, error) {
	h := r.inner.DB()

	if name == "tip" {
		// fossil's "tip" is the newest check-in, but when there is none it
		// falls through to the ordinary forms below rather than stopping, so
		// a literal tag named "tip" could still match. (No sym-tip tag can
		// exist without a check-in, so this only ever resolves to not-found.)
		if rid, err := newestCheckin(h); err != nil || rid != 0 {
			return rid, err
		}
	}
	if rest, ok := strings.CutPrefix(name, "rid:"); ok {
		return ridLiteral(h, rest)
	}
	if rest, ok := strings.CutPrefix(name, "tag:"); ok {
		return newestWithSymTag(h, rest)
	}
	if rest, ok := strings.CutPrefix(name, "root:"); ok {
		return r.startOfBranchBy(rest, branchOrigin)
	}
	if rest, ok := strings.CutPrefix(name, "start:"); ok {
		return r.startOfBranchBy(rest, branchFirst)
	}
	if rest, ok := strings.CutPrefix(name, "merge-in:"); ok {
		return r.startOfBranchBy(rest, branchMergeIn)
	}
	if base, julian, ok := splitNameTimestamp(name); ok {
		return newestSymTagBefore(h, base, julian)
	}
	// A hash prefix is tried before a plain tag, as fossil does; a hex string
	// that matches no artifact falls through to the tag lookup.
	if rid, matched, err := hashPrefix(h, name); err != nil || matched {
		return rid, err
	}
	if rid, err := newestWithSymTag(h, name); err != nil || rid != 0 {
		return rid, err
	}
	if trimmed, ok := trimTrailingPunct(name); ok {
		return r.resolveSymbolic(trimmed)
	}
	return 0, nil
}

// newestCheckin is fossil's "tip": the newest check-in by event.mtime.
func newestCheckin(h *db.DB) (int64, error) {
	var rid int64
	err := h.QueryRow(
		"SELECT objid FROM event WHERE type='ci' ORDER BY mtime DESC LIMIT 1",
	).Scan(&rid)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, err
	}
	return rid, nil
}

// ridLiteral resolves "rid:N" to RID N when that artifact exists.
func ridLiteral(h *db.DB, digits string) (int64, error) {
	n, err := strconv.ParseInt(digits, 10, 64)
	if err != nil || n <= 0 {
		return 0, nil
	}
	var rid int64
	err = h.QueryRow("SELECT rid FROM blob WHERE rid = ?", n).Scan(&rid)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, err
	}
	return rid, nil
}

// newestWithSymTag is fossil's most_recent_event_with_tag: the check-in
// carrying sym-<tag> with the greatest event.mtime, 0 when none does.
func newestWithSymTag(h *db.DB, tag string) (int64, error) {
	var rid int64
	err := h.QueryRow(`
		SELECT event.objid FROM tag
		JOIN tagxref ON tagxref.tagid = tag.tagid
		JOIN event ON event.objid = tagxref.rid
		WHERE tag.tagname = ?
		  AND tagxref.tagtype > 0
		ORDER BY event.mtime DESC LIMIT 1`,
		"sym-"+tag).Scan(&rid)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, err
	}
	return rid, nil
}

// newestSymTagBefore is the "NAME:TIMESTAMP" form: the check-in carrying
// sym-<base> with the greatest event.mtime at or before julian.
func newestSymTagBefore(h *db.DB, base string, julian float64) (int64, error) {
	var rid int64
	err := h.QueryRow(`
		SELECT event.objid FROM tag
		JOIN tagxref ON tagxref.tagid = tag.tagid
		JOIN event ON event.objid = tagxref.rid
		WHERE tag.tagname = ?
		  AND tagxref.tagtype > 0
		  AND event.mtime <= ?
		ORDER BY event.mtime DESC LIMIT 1`,
		"sym-"+base, julian).Scan(&rid)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, err
	}
	return rid, nil
}

// startOfBranchBy resolves branch name BR recursively, then returns the point
// in BR that mode selects (fossil's root:/start:/merge-in:).
func (r *Repo) startOfBranchBy(name string, mode int) (int64, error) {
	rid, err := r.resolveSymbolic(name)
	if err != nil || rid <= 0 {
		return rid, err
	}
	return startOfBranch(r.inner.DB(), rid, mode)
}

// startOfBranch is fossil's start_of_branch (src/name.c): walk rid's primary-
// parent chain while it stays on rid's branch. mode branchOrigin returns the
// off-branch parent the branch sprang from; branchFirst the deepest on-branch
// ancestor (the branch's first check-in); branchMergeIn the youngest ancestor,
// by any parent, that is on the branch the origin's parent is on.
func startOfBranch(h *db.DB, rid int64, mode int) (int64, error) {
	br, err := branchOfRID(h, rid)
	if err != nil {
		return 0, err
	}
	ans := rid
	var pid int64
	err = h.QueryRow(`
		WITH RECURSIVE par(pid, ex, cnt) AS (
		  SELECT pid,
		    EXISTS(SELECT 1 FROM tagxref WHERE tagid=(SELECT tagid FROM tag WHERE tagname='branch')
		            AND tagtype>0 AND value=? AND rid=plink.pid),
		    1
		  FROM plink WHERE cid=? AND isprim=1
		  UNION ALL
		  SELECT plink.pid,
		    EXISTS(SELECT 1 FROM tagxref WHERE tagid=(SELECT tagid FROM tag WHERE tagname='branch')
		            AND tagtype>0 AND value=? AND rid=plink.pid),
		    1+par.cnt
		  FROM plink, par
		  WHERE cid=par.pid AND isprim=1 AND par.ex
		  LIMIT 100000
		)
		SELECT pid FROM par WHERE ex>=? ORDER BY cnt DESC LIMIT 1`,
		br, rid, br, mode%2).Scan(&pid)
	switch {
	case err == nil:
		ans = pid
	case errors.Is(err, sql.ErrNoRows):
		// No primary parent on rid's branch: ans stays rid.
	default:
		return 0, err
	}
	if mode == branchMergeIn && ans > 0 {
		br2, err := branchOfRID(h, ans)
		if err != nil {
			return 0, err
		}
		return youngestAncestorInBranch(h, rid, br2)
	}
	return ans, nil
}

// branchOfRID is fossil's branch_of_rid: rid's active branch, the default
// branch (the main-branch setting) when it carries no active branch tag.
func branchOfRID(h *db.DB, rid int64) (string, error) {
	var br string
	err := h.QueryRow(`
		SELECT value FROM tagxref
		WHERE rid = ?
		  AND tagid = (SELECT tagid FROM tag WHERE tagname='branch')
		  AND tagtype > 0`, rid).Scan(&br)
	if errors.Is(err, sql.ErrNoRows) {
		return repo.MainBranch(h)
	}
	if err != nil {
		return "", err
	}
	return br, nil
}

// youngestAncestorInBranch is fossil's compute_youngest_ancestor_in_branch
// (src/descendants.c): the youngest ancestor of rid, by any parent edge, that
// is on branch.
func youngestAncestorInBranch(h *db.DB, rid int64, branch string) (int64, error) {
	var ans int64
	err := h.QueryRow(`
		WITH RECURSIVE ancestor(rid, mtime) AS (
		  SELECT ?, mtime FROM event WHERE objid=?
		  UNION
		  SELECT plink.pid, event.mtime
		    FROM ancestor, plink, event
		   WHERE plink.cid=ancestor.rid AND event.objid=plink.pid
		   ORDER BY mtime DESC
		)
		SELECT ancestor.rid FROM ancestor
		 WHERE EXISTS(SELECT 1 FROM tagxref
		              WHERE tagid=(SELECT tagid FROM tag WHERE tagname='branch')
		                AND tagxref.rid=ancestor.rid AND value=? AND tagtype>0)
		 ORDER BY mtime DESC LIMIT 1`, rid, rid, branch).Scan(&ans)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, err
	}
	return ans, nil
}

// hashPrefix matches name as an artifact hash or 4–64 character hex prefix
// against blob.uuid, after stripping optional surrounding brackets. matched
// reports whether name was a hex form that found one or more artifacts: one
// match returns its rid; more than one returns ErrAmbiguousVersion. A non-hex
// name, or a hex prefix that matches nothing, returns matched=false so the
// caller falls through to a tag lookup.
func hashPrefix(h *db.DB, name string) (rid int64, matched bool, err error) {
	s := name
	s = strings.TrimPrefix(s, "[")
	s = strings.TrimSuffix(s, "]")
	if len(s) < 4 || len(s) > 64 || !isHex(s) {
		return 0, false, nil
	}
	rows, err := h.Query("SELECT rid FROM blob WHERE uuid GLOB ?", strings.ToLower(s)+"*")
	if err != nil {
		return 0, false, err
	}
	defer rows.Close()
	var ids []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return 0, false, err
		}
		ids = append(ids, id)
		if len(ids) > 1 {
			break
		}
	}
	if err := rows.Err(); err != nil {
		return 0, false, err
	}
	switch len(ids) {
	case 0:
		return 0, false, nil
	case 1:
		return ids[0], true, nil
	default:
		return 0, true, ErrAmbiguousVersion
	}
}

// splitNameTimestamp detects fossil's "NAME:TIMESTAMP" form: the first colon
// whose suffix is at least seven characters (fossil's i<nTag-8 gate) and
// parses as a date-time. It returns the base name and the inclusive upper
// bound of the timestamp as a text Julian day matching event.mtime.
func splitNameTimestamp(name string) (base string, julian float64, ok bool) {
	i := strings.IndexByte(name, ':')
	if i < 0 || len(name)-i-1 < 7 {
		return "", 0, false
	}
	t, ok := parseFossilTimestamp(name[i+1:])
	if !ok {
		return "", 0, false
	}
	return name[:i], fsltype.TimeToTextJulian(t), true
}

// parseFossilTimestamp parses the date-time forms fossil accepts and rounds
// the result UP to the last millisecond of the given precision, as fossil's
// fossil_roundup_date and fossil_expand_datetime do (src/name.c): a bare date
// becomes 23:59:59.999, HH:MM becomes :59.999, HH:MM:SS becomes .999. The time
// is read as UTC, matching fossil's default timeline-utc setting.
//
// Supported: the compact YYYYMMDD / YYYYMMDDHHMM / YYYYMMDDHHMMSS forms, the
// punctuated date / minute / second forms with a 'T' or space separator, a
// fractional-second suffix of one to three digits, and an optional trailing
// 'Z'/'z' or 'UTC'. The bare YYYY-MM and hour-only forms are not supported
// (they do not resolve to a check-in time in fossil either).
func parseFossilTimestamp(s string) (time.Time, bool) {
	s = strings.TrimSpace(s)
	if n := len(s); n >= 3 && strings.EqualFold(s[n-3:], "utc") {
		s = strings.TrimSpace(s[:n-3])
	} else if n >= 1 && (s[n-1] == 'z' || s[n-1] == 'Z') {
		s = s[:n-1]
	}
	if exp, ok := expandCompactDate(s); ok {
		s = exp
	}
	s = strings.Replace(s, "T", " ", 1)

	// Fractional seconds: round up to the last millisecond of the stated
	// precision, as fossil pads the fraction with 9s and then rounds up.
	if head, frac, found := strings.Cut(s, "."); found {
		t, err := time.ParseInLocation("2006-01-02 15:04:05", head, time.UTC)
		if err != nil || len(frac) == 0 || len(frac) > 3 || !isDigits(frac) {
			return time.Time{}, false
		}
		v, _ := strconv.Atoi(frac + strings.Repeat("9", 3-len(frac)))
		if v < 999 {
			v++
		}
		return t.Add(time.Duration(v) * time.Millisecond), true
	}
	if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC); err == nil {
		return t.Add(999 * time.Millisecond), true
	}
	if t, err := time.ParseInLocation("2006-01-02 15:04", s, time.UTC); err == nil {
		return t.Add(59*time.Second + 999*time.Millisecond), true
	}
	if t, err := time.ParseInLocation("2006-01-02", s, time.UTC); err == nil {
		return t.Add(24*time.Hour - time.Millisecond), true
	}
	return time.Time{}, false
}

// expandCompactDate expands fossil's digit-only date forms (src/name.c
// fossil_expand_datetime): YYYYMMDD, YYYYMMDDHHMM and YYYYMMDDHHMMSS to their
// punctuated equivalents, leaving parseFossilTimestamp to round them up.
func expandCompactDate(s string) (string, bool) {
	if !isDigits(s) {
		return "", false
	}
	switch len(s) {
	case 8:
		return s[:4] + "-" + s[4:6] + "-" + s[6:8], true
	case 12:
		return s[:4] + "-" + s[4:6] + "-" + s[6:8] + " " + s[8:10] + ":" + s[10:12], true
	case 14:
		return s[:4] + "-" + s[4:6] + "-" + s[6:8] + " " +
			s[8:10] + ":" + s[10:12] + ":" + s[12:14], true
	}
	return "", false
}

func isDigits(s string) bool {
	if s == "" {
		return false
	}
	for i := 0; i < len(s); i++ {
		if s[i] < '0' || s[i] > '9' {
			return false
		}
	}
	return true
}

// trimTrailingPunct drops one or two trailing punctuation characters from a
// name, as fossil does for a name pasted from prose. It returns ok=false when
// there is nothing fossil would trim.
func trimTrailingPunct(name string) (string, bool) {
	n := len(name)
	if n > 4 && isTrailingPunct(name[n-1]) && !isTrailingPunct(name[n-2]) {
		return name[:n-1], true
	}
	if n > 5 && isTrailingPunct(name[n-1]) && isTrailingPunct(name[n-2]) &&
		!isTrailingPunct(name[n-3]) {
		return name[:n-2], true
	}
	return "", false
}

func isTrailingPunct(c byte) bool {
	switch c {
	case '.', '_', ')', '>', '!', '?', ',':
		return true
	}
	return false
}

func isHex(s string) bool {
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
			return false
		}
	}
	return true
}

// ReadFileAt reads filePath from the checkin identified by a symbolic version
// name (e.g. "tip", "trunk", a branch name, a UUID, or a UUID prefix).
// It calls ResolveVersion to obtain the RID, then delegates to ReadFile.
// Use ReadFile directly when you already have a numeric RID.
func (r *Repo) ReadFileAt(version string, filePath string) ([]byte, error) {
	rid, err := r.ResolveVersion(version)
	if err != nil {
		return nil, fmt.Errorf("libfossil: read file at %q: %w", version, err)
	}
	return r.ReadFile(rid, filePath)
}

// blobAt returns the bytes of filePath as they exist in the given checkin.
// A checkin that does not contain filePath returns (nil, nil); callers treat
// that as "empty side" for diff purposes. Errors surface only for real I/O
// or consistency problems (unknown RID, missing blob for a listed UUID).
func blobAt(r *Repo, checkinRID int64, filePath string) ([]byte, error) {
	if filePath == "" {
		return nil, nil
	}
	f, found, err := manifest.LookupFile(r.inner, fsltype.FslID(checkinRID), filePath)
	if err != nil {
		return nil, err
	}
	if !found {
		return nil, nil
	}
	rid, ok := content.AvailableByUUID(r.DB(), f.UUID)
	if !ok {
		return nil, fmt.Errorf("blob not found for uuid %s", f.UUID)
	}
	// Bare Expand: Diff is pairwise (two named checkins), not a walk over a
	// file's whole history, so each side's blob is expanded once. There is
	// no deep overlapping delta chain here for content.Cache to amortize --
	// that pattern lives in annotate, which is cached.
	return content.Expand(r.DB(), rid)
}