Skip to content
go-libfossil Artifact [f3ee89cab0]
Login

Artifact [f3ee89cab0]

Artifact f3ee89cab0b14cf848490dc0e2afa0b474dfbd110435dfa8916f119d676eb7eb:


package checkout

import (
	"context"
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"testing"
	"time"

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

// These tests drive the sparse worktree of draft-fossil-sparse-cone-00 §4:
// a checkout with a cone materializes and tracks only the covered files,
// commits carry the uncovered ones from the baseline unchanged, and every
// operation that would act on an uncovered path refuses by naming the cone.

// sparseFiles is the fixture's first check-in.
var sparseFiles = map[string]string{
	"README":               "readme\n",
	"docs/d.txt":           "docs\n",
	"src/a.c":              "a\n",
	"src/b.c":              "b\n",
	"src/vendor/v.c":       "vendor\n",
	"src/vendor/keep/k.c":  "keep\n",
	"src.txt":              "sibling\n",
	"tools/t.c":            "tools\n",
	"tools/deep/x/y/z.txt": "deep\n",
}

// sparseCone covers src, carves out src/vendor and keeps src/vendor/keep.
func sparseCone(t *testing.T) *cone.Cone {
	t.Helper()
	c, err := cone.New([]string{"src", "src/vendor/keep"}, []string{"src/vendor"})
	if err != nil {
		t.Fatal(err)
	}
	return c
}

var sparseCovered = []string{"src/a.c", "src/b.c", "src/vendor/keep/k.c"}

type sparseFixture struct {
	repo *repo.Repo
	c1   libfossil.FslID
	time time.Time
}

// newSparseRepo commits sparseFiles, as a tree manifest when tree is set.
func newSparseRepo(t *testing.T, tree bool) *sparseFixture {
	t.Helper()
	r, err := repo.CreateWithEnv(t.TempDir()+"/sparse.fossil", "test", simio.RealEnv(), "")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { r.Close() })
	if tree {
		for k, v := range map[string]string{
			manifest.SettingEnableSubmanifests: "on", manifest.SettingSubmanifestK: "1",
		} {
			if err := r.SetConfig(k, v); err != nil {
				t.Fatal(err)
			}
		}
	}
	f := &sparseFixture{repo: r, time: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}
	f.c1 = f.commit(t, sparseFiles, 0)
	d, err := manifest.GetManifest(r, f.c1)
	if err != nil {
		t.Fatal(err)
	}
	if tree != (len(d.S) > 0) {
		t.Fatalf("tree=%v but the check-in carries %d S cards", tree, len(d.S))
	}
	return f
}

func (f *sparseFixture) commit(t *testing.T, files map[string]string, parent libfossil.FslID) libfossil.FslID {
	t.Helper()
	var mf []manifest.File
	for name, text := range files {
		mf = append(mf, manifest.File{Name: name, Content: []byte(text)})
	}
	sort.Slice(mf, func(i, j int) bool { return mf[i].Name < mf[j].Name })
	f.time = f.time.Add(time.Hour)
	rid, _, err := manifest.Checkin(f.repo, manifest.CheckinOpts{
		Files: mf, Comment: "c", User: "test", Parent: parent, Time: f.time,
	})
	if err != nil {
		t.Fatal(err)
	}
	return rid
}

// open creates a checkout with cone c (nil for dense) and extracts rid.
func (f *sparseFixture) open(t *testing.T, c *cone.Cone, rid libfossil.FslID) *Checkout {
	t.Helper()
	co, err := Create(f.repo, t.TempDir(), CreateOpts{Cone: c})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { co.Close() })
	if err := co.Extract(rid, ExtractOpts{Force: true}); err != nil {
		t.Fatal(err)
	}
	return co
}

// onDisk lists the regular files under the checkout, the checkout database
// and its journal files excluded.
func onDisk(t *testing.T, co *Checkout) []string {
	t.Helper()
	var names []string
	err := filepath.WalkDir(co.Dir(), func(path string, d fs.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return err
		}
		rel, err := filepath.Rel(co.Dir(), path)
		if err != nil {
			return err
		}
		if !strings.HasPrefix(rel, PreferredDBName()) {
			names = append(names, filepath.ToSlash(rel))
		}
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	sort.Strings(names)
	return names
}

func writeFile(t *testing.T, co *Checkout, name, text string) {
	t.Helper()
	path := filepath.Join(co.Dir(), name)
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
		t.Fatal(err)
	}
}

func vfileCount(t *testing.T, co *Checkout) int {
	t.Helper()
	var n int
	if err := co.db.QueryRow("SELECT count(*) FROM vfile").Scan(&n); err != nil {
		t.Fatal(err)
	}
	return n
}

// §4.1: a checkout given a cone materializes exactly the covered files,
// over flat and tree manifests; the record keeps every file, and the cone
// survives a reopen.
func TestSparseMaterializesExactlyTheCone(t *testing.T) {
	for _, tree := range []bool{false, true} {
		f := newSparseRepo(t, tree)
		co := f.open(t, sparseCone(t), f.c1)
		if got := onDisk(t, co); !slices.Equal(got, sparseCovered) {
			t.Fatalf("tree=%v: on disk %q, want %q", tree, got, sparseCovered)
		}
		if n := vfileCount(t, co); n != len(sparseFiles) {
			t.Fatalf("tree=%v: vfile holds %d rows, want the full record of %d", tree, n, len(sparseFiles))
		}
		reopened, err := Open(f.repo, co.Dir(), OpenOpts{})
		if err != nil {
			t.Fatal(err)
		}
		if got := reopened.Cone(); got == nil || !got.Equal(sparseCone(t)) {
			t.Fatalf("tree=%v: reopened cone %v", tree, got)
		}
		reopened.Close()
	}
}

func TestSparseOpenRefusesCorruptCone(t *testing.T) {
	f := newSparseRepo(t, false)
	co := f.open(t, sparseCone(t), f.c1)
	if err := setVVar(co.db, coneVVar, "02zz"); err != nil {
		t.Fatal(err)
	}
	if _, err := Open(f.repo, co.Dir(), OpenOpts{}); err == nil || !strings.Contains(err.Error(), "cone") {
		t.Fatalf("Open with a corrupt cone: %v", err)
	}
}

// §4.1: status works within the cone. An uncovered path is neither missing
// nor extra, a stray uncovered file on disk is not reported, and an
// in-cone edit is.
func TestSparseStatusStaysInCone(t *testing.T) {
	f := newSparseRepo(t, false)
	co := f.open(t, sparseCone(t), f.c1)
	if has, err := co.HasChanges(); err != nil || has {
		t.Fatalf("HasChanges = %v, %v on a fresh sparse checkout", has, err)
	}
	if got := changedNames(t, co); len(got) != 0 {
		t.Fatalf("fresh sparse checkout reports %q", got)
	}
	writeFile(t, co, "tools/stray.c", "stray\n")
	writeFile(t, co, "src/vendor/v.c", "materialized by hand\n")
	writeFile(t, co, "src/a.c", "edited\n")
	extras := -1
	co.obs = &testObserver{onScanCompleted: func(_ context.Context, e ScanEnd) { extras = e.FilesExtra }}
	if got := changedNames(t, co); !slices.Equal(got, []string{"src/a.c"}) {
		t.Fatalf("changes = %q, want only src/a.c", got)
	}
	if extras != 0 {
		t.Fatalf("scan counted %d extra files, want 0: uncovered paths are not untracked", extras)
	}
}

// §4.1: tracking requests outside the cone are refused, naming the cone.
func TestSparseRefusesTrackingOutsideCone(t *testing.T) {
	f := newSparseRepo(t, false)
	co := f.open(t, sparseCone(t), f.c1)
	writeFile(t, co, "tools/new.c", "new\n")
	writeFile(t, co, "src/vendor/new.c", "new\n")
	writeFile(t, co, "src/new.c", "new\n")
	for _, name := range []string{"tools/new.c", "src/vendor/new.c", "tools/t.c"} {
		_, err := co.Manage(ManageOpts{Paths: []string{name}})
		assertOutsideCone(t, err, name)
	}
	if counts, err := co.Manage(ManageOpts{Paths: []string{"src/new.c"}}); err != nil || counts.Added != 1 {
		t.Fatalf("Manage(src/new.c) = %+v, %v", counts, err)
	}
	assertOutsideCone(t, co.Unmanage(UnmanageOpts{Paths: []string{"tools/t.c"}}), "tools/t.c")
	assertOutsideCone(t, co.Rename(RenameOpts{From: "src/a.c", To: "tools/a.c", DoFsMove: true}), "tools/a.c")
	assertOutsideCone(t, co.Rename(RenameOpts{From: "tools/t.c", To: "src/t.c"}), "tools/t.c")
	assertOutsideCone(t, co.Revert(RevertOpts{Paths: []string{"tools/t.c"}}), "tools/t.c")
	if err := co.Enqueue(EnqueueOpts{Paths: []string{"tools"}}); err != nil {
		t.Fatal(err)
	}
	_, _, err := co.Commit(CommitOpts{Message: "m", User: "u"})
	assertOutsideCone(t, err, "tools")
}

func assertOutsideCone(t *testing.T, err error, path string) {
	t.Helper()
	if !errors.Is(err, ErrOutsideCone) {
		t.Fatalf("%s: error %v, want ErrOutsideCone", path, err)
	}
	for _, want := range []string{path, "src, !src/vendor, src/vendor/keep"} {
		if !strings.Contains(err.Error(), want) {
			t.Fatalf("%s: error %q does not name %q", path, err, want)
		}
	}
}

// §4.2: a sparse commit's file set is the in-cone files as observed plus the
// baseline's out-of-cone entries unchanged, and it is the very check-in a
// dense checkout makes of the same change: a reader cannot tell them apart.
func TestSparseCommitIsTheDenseCommit(t *testing.T) {
	for _, tree := range []bool{false, true} {
		var uuids []string
		var sparse *Checkout
		for _, c := range []*cone.Cone{sparseCone(t), nil} {
			// One repository each: the two commits are the same artifact.
			f := newSparseRepo(t, tree)
			co := f.open(t, c, f.c1)
			if c != nil {
				sparse = co
			}
			writeFile(t, co, "src/a.c", "a edited\n")
			writeFile(t, co, "src/vendor/keep/new.c", "new\n")
			if _, err := co.Manage(ManageOpts{Paths: []string{"src/vendor/keep/new.c"}}); err != nil {
				t.Fatal(err)
			}
			if err := co.Unmanage(UnmanageOpts{Paths: []string{"src/b.c"}}); err != nil {
				t.Fatal(err)
			}
			rid, uuid, err := co.Commit(CommitOpts{Message: "edit", User: "u", Time: f.time.Add(time.Hour)})
			if err != nil {
				t.Fatalf("tree=%v: Commit: %v", tree, err)
			}
			if err := f.repo.WithTx(func(tx *db.Tx) error { return manifest.VerifyRCard(tx, nil, rid) }); err != nil {
				t.Fatalf("tree=%v: R card: %v", tree, err)
			}
			uuids = append(uuids, uuid)
		}
		if uuids[0] != uuids[1] {
			t.Fatalf("tree=%v: sparse commit %s differs from the dense commit %s", tree, uuids[0], uuids[1])
		}
		if got := onDisk(t, sparse); slices.Contains(got, "tools/t.c") {
			t.Fatalf("tree=%v: the commit materialized out-of-cone files: %q", tree, got)
		}
	}
}

// §4.4: update applies out-of-cone changes to the record without
// materializing them, and reports how many it applied.
func TestSparseUpdateAppliesOutOfConeToRecord(t *testing.T) {
	f := newSparseRepo(t, false)
	next := maps(sparseFiles, map[string]string{
		"src/a.c": "a2\n", "tools/t.c": "tools2\n", "docs/new.txt": "new\n",
	}, "README")
	c2 := f.commit(t, next, f.c1)
	co := f.open(t, sparseCone(t), f.c1)
	res, err := co.Update(UpdateOpts{TargetRID: c2})
	if err != nil {
		t.Fatal(err)
	}
	if !slices.Equal(res.FilesWritten, []string{"src/a.c"}) || len(res.FilesRemoved) != 0 {
		t.Fatalf("Update wrote %q, removed %q", res.FilesWritten, res.FilesRemoved)
	}
	if res.OutOfCone != 3 {
		t.Fatalf("Update applied %d out-of-cone changes, want 3", res.OutOfCone)
	}
	if got := onDisk(t, co); !slices.Equal(got, sparseCovered) {
		t.Fatalf("on disk %q after update", got)
	}
	writeFile(t, co, "src/b.c", "b2\n")
	rid, _, err := co.Commit(CommitOpts{Message: "m", User: "u", Time: f.time.Add(time.Hour)})
	if err != nil {
		t.Fatal(err)
	}
	want := maps(next, map[string]string{"src/b.c": "b2\n"})
	assertCheckinContent(t, f.repo, rid, want)
}

// maps returns base overlaid with set, minus the names in drop.
func maps(base, set map[string]string, drop ...string) map[string]string {
	out := map[string]string{}
	for k, v := range base {
		out[k] = v
	}
	for k, v := range set {
		out[k] = v
	}
	for _, k := range drop {
		delete(out, k)
	}
	return out
}

func assertCheckinContent(t *testing.T, r *repo.Repo, rid libfossil.FslID, want map[string]string) {
	t.Helper()
	files, err := manifest.ListFiles(r, rid)
	if err != nil {
		t.Fatal(err)
	}
	got := map[string]string{}
	for _, file := range files {
		frid, ok := content.AvailableByUUID(r.DB(), file.UUID)
		if !ok {
			t.Fatalf("%s: content missing", file.Name)
		}
		data, err := content.Expand(r.DB(), frid)
		if err != nil {
			t.Fatal(err)
		}
		got[file.Name] = string(data)
	}
	if len(got) != len(want) {
		t.Fatalf("check-in holds %d files, want %d", len(got), len(want))
	}
	for name, text := range want {
		if got[name] != text {
			t.Fatalf("%s = %q, want %q", name, got[name], text)
		}
	}
}

// §4.4: a merge that would change an out-of-cone file is refused, naming
// the cone, and changes nothing; one that stays in the cone runs.
func TestSparseMergeAcrossConeRefused(t *testing.T) {
	f := newSparseRepo(t, false)
	theirs := f.commit(t, maps(sparseFiles, map[string]string{"tools/t.c": "theirs\n"}), f.c1)
	ours := f.commit(t, maps(sparseFiles, map[string]string{"src/a.c": "ours\n"}), f.c1)
	co := f.open(t, sparseCone(t), ours)
	_, err := co.Merge(MergeOpts{Version: theirs})
	assertOutsideCone(t, err, "tools/t.c")
	var n int
	if err := co.db.QueryRow("SELECT count(*) FROM vmerge").Scan(&n); err != nil || n != 0 {
		t.Fatalf("a refused merge left %d vmerge rows (%v)", n, err)
	}

	inCone := f.commit(t, maps(sparseFiles, map[string]string{"src/b.c": "theirs\n"}), f.c1)
	res, err := co.Merge(MergeOpts{Version: inCone})
	if err != nil || len(res.Files) != 1 || res.Files[0].Name != "src/b.c" {
		t.Fatalf("in-cone merge = %+v, %v", res, err)
	}
}

// §3.5: widening materializes the newly covered files, narrowing removes
// the newly uncovered ones, and the cone survives both.
func TestSetConeWidensAndNarrows(t *testing.T) {
	f := newSparseRepo(t, false)
	co := f.open(t, sparseCone(t), f.c1)
	wider, err := sparseCone(t).With([]string{"tools"}, nil)
	if err != nil {
		t.Fatal(err)
	}
	res, err := co.SetCone(wider)
	if err != nil {
		t.Fatal(err)
	}
	wantAdded := []string{"tools/deep/x/y/z.txt", "tools/t.c"}
	if !slices.Equal(res.Materialized, wantAdded) || len(res.Dematerialized) != 0 {
		t.Fatalf("widen: %+v", res)
	}
	if got := onDisk(t, co); !slices.Equal(got, append(slices.Clone(sparseCovered), wantAdded...)) {
		t.Fatalf("on disk after widening: %q", got)
	}

	narrower, err := cone.New([]string{"src/a.c"}, nil)
	if err != nil {
		t.Fatal(err)
	}
	res, err = co.SetCone(narrower)
	if err != nil {
		t.Fatal(err)
	}
	want := []string{"src/b.c", "src/vendor/keep/k.c", "tools/deep/x/y/z.txt", "tools/t.c"}
	if !slices.Equal(res.Dematerialized, want) || len(res.Materialized) != 0 {
		t.Fatalf("narrow: %+v", res)
	}
	if got := onDisk(t, co); !slices.Equal(got, []string{"src/a.c"}) {
		t.Fatalf("on disk after narrowing: %q", got)
	}
	if _, err := os.Stat(filepath.Join(co.Dir(), "tools")); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("narrowing left the emptied tools directory: %v", err)
	}
	if !co.Cone().Equal(narrower) {
		t.Fatalf("cone after narrowing: %v", co.Cone())
	}

	if _, err := co.SetCone(nil); err != nil {
		t.Fatal(err)
	}
	if co.Cone() != nil || len(onDisk(t, co)) != len(sparseFiles) {
		t.Fatalf("dense again: cone %v, on disk %q", co.Cone(), onDisk(t, co))
	}
}

// Narrowing never discards work: a file with local changes that would leave
// the cone is refused by name, and so is a widening that would overwrite an
// untracked file with different content.
func TestSetConeProtectsWork(t *testing.T) {
	f := newSparseRepo(t, false)
	co := f.open(t, sparseCone(t), f.c1)
	writeFile(t, co, "src/b.c", "edited\n")
	narrower, err := cone.New([]string{"src/a.c"}, nil)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := co.SetCone(narrower); err == nil || !strings.Contains(err.Error(), "src/b.c") {
		t.Fatalf("narrowing over an edit: %v", err)
	}
	if !co.Cone().Equal(sparseCone(t)) {
		t.Fatal("a refused narrowing changed the cone")
	}
	writeFile(t, co, "tools/t.c", "not the committed content\n")
	wider, err := sparseCone(t).With([]string{"tools"}, nil)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := co.SetCone(wider); err == nil || !strings.Contains(err.Error(), "tools/t.c") {
		t.Fatalf("widening over an untracked file: %v", err)
	}
}