Artifact f3ee89cab0b14cf848490dc0e2afa0b474dfbd110435dfa8916f119d676eb7eb:
- File
internal/checkout/sparse_test.go
— part of check-in
[3672f3d4fb]
at
2026-10-18 18:26:58
on branch feat/submanifest-reader
— feat(checkout): the sparse worktree of draft-fossil-sparse-cone-00 §4
A checkout created with a cone materializes, scans and tracks only the files it covers; vfile stays the full record, so fossil still reads the checkout. Commits carry uncovered files from the baseline by hash, update applies uncovered changes to the record only, and tracking, renaming, reverting or merging across the cone is refused naming it. SetCone widens and narrows. (user: [email protected], size: 15665) [annotate] [blame] [check-ins using]
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) } }