Skip to content
go-libfossil Artifact [2132cc832c]
Login

Artifact [2132cc832c]

Artifact 2132cc832c184995e6fe8813b733bf2005088c6b001a8bb25f17deead5769459:


package libfossil

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

	"github.com/danmestas/libfossil/internal/annotate"
	"github.com/danmestas/libfossil/internal/content"
	"github.com/danmestas/libfossil/internal/diff"
	"github.com/danmestas/libfossil/internal/fsltype"
	"github.com/danmestas/libfossil/internal/manifest"
)

// 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.
//
// Resolution order:
//  1. "" or "tip"  — newest checkin by mtime from the event table.
//  2. "trunk"      — tip of the trunk branch via tagxref/tag; falls back to "tip"
//     if the repository has no trunk tag.
//  3. Named branch — tag lookup for "sym-<name>" in tagxref/tag (e.g. "feature-x"
//     resolves via sym-feature-x).
//  4. Full UUID (≥40 chars) — exact match against blob.uuid.
//  5. UUID prefix (4–39 chars) — unique-prefix match; returns ErrAmbiguousVersion
//     if more than one artifact matches.
//
// An empty result or no match returns ErrVersionNotFound (wrapped).
// An ambiguous prefix returns ErrAmbiguousVersion (wrapped).
func (r *Repo) ResolveVersion(name string) (int64, error) {
	db := r.inner.DB()

	switch name {
	case "", "tip":
		var rid int64
		err := db.QueryRow(
			"SELECT objid FROM event WHERE type='ci' ORDER BY mtime DESC LIMIT 1",
		).Scan(&rid)
		if errors.Is(err, sql.ErrNoRows) {
			return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, ErrVersionNotFound)
		}
		if err != nil {
			return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
		}
		return rid, nil

	case "trunk":
		var rid int64
		err := db.QueryRow(`
			SELECT tagxref.rid FROM tagxref
			JOIN tag ON tag.tagid = tagxref.tagid
			WHERE tag.tagname = 'sym-trunk'
			  AND tagxref.tagtype > 0
			ORDER BY tagxref.mtime DESC LIMIT 1`,
		).Scan(&rid)
		if err == nil {
			return rid, nil
		}
		if !errors.Is(err, sql.ErrNoRows) {
			return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
		}
		// No trunk tag — fall back to tip.
		return r.ResolveVersion("tip")

	default:
		// First try as a named branch ("sym-<name>").
		var rid int64
		err := db.QueryRow(`
			SELECT tagxref.rid FROM tagxref
			JOIN tag ON tag.tagid = tagxref.tagid
			WHERE tag.tagname = ?
			  AND tagxref.tagtype > 0
			ORDER BY tagxref.mtime DESC LIMIT 1`,
			"sym-"+name,
		).Scan(&rid)
		if err == nil {
			return rid, nil
		}
		if !errors.Is(err, sql.ErrNoRows) {
			return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
		}

		// Try as a UUID or UUID prefix.
		rows, err := db.Query(
			"SELECT rid FROM blob WHERE uuid LIKE ?", name+"%",
		)
		if err != nil {
			return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
		}
		defer rows.Close()

		var matches []int64
		for rows.Next() {
			var id int64
			if err := rows.Scan(&id); err != nil {
				return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
			}
			matches = append(matches, id)
		}
		if err := rows.Err(); err != nil {
			return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
		}

		switch len(matches) {
		case 0:
			return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, ErrVersionNotFound)
		case 1:
			return matches[0], nil
		default:
			return 0, fmt.Errorf("libfossil: resolve version %q matches %d artifacts: %w",
				name, len(matches), ErrAmbiguousVersion)
		}
	}
}

// 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) {
	files, err := manifest.ListFiles(r.inner, fsltype.FslID(checkinRID))
	if err != nil {
		return nil, err
	}
	for _, f := range files {
		if f.Name != filePath {
			continue
		}
		rid, ok := content.AvailableByUUID(r.DB(), f.UUID)
		if !ok {
			return nil, fmt.Errorf("blob not found for uuid %s", f.UUID)
		}
		return content.Expand(r.DB(), rid)
	}
	return nil, nil
}