Artifact 74c2b9b554f22aa7810a30955b8729c3b5030eb5ddb031e99bc29310aff3baa6:
- File
internal/checkout/sparse_test.go
— part of check-in
[47f89f0798]
at
2026-10-19 14:41:42
on branch feat/submanifest-reader
— Merge branch 'feat/submanifest-reader' into feat/cones
Update's rewrite meets the sparse worktree: a file outside the cone is a record-only step, counted in OutOfCone, and never planned, scanned, checked, written, backed up as NAME-original or chmodded; reloading vfile moves its record to the target. SetCone records the mtime of each file it materializes, as Extract now does. (user: [email protected], size: 16571) [annotate] [blame] [check-ins using] [more...]
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) // An uncovered file standing where the target adds one is not the // update's business: it is neither backed up nor replaced. writeFile(t, co, "docs/new.txt", "stray\n") if err := os.Chmod(filepath.Join(co.Dir(), "docs/new.txt"), 0o600); err != nil { t.Fatal(err) } res, err := co.Update(UpdateOpts{TargetRID: c2}) if err != nil { t.Fatal(err) } if data, err := os.ReadFile(filepath.Join(co.Dir(), "docs/new.txt")); err != nil || string(data) != "stray\n" { t.Fatalf("update touched an uncovered stray file: %q, %v", data, err) } if info, err := os.Stat(filepath.Join(co.Dir(), "docs/new.txt")); err != nil || info.Mode().Perm() != 0o600 { t.Fatalf("update changed an uncovered file's mode: %v, %v", info, err) } if _, err := os.Stat(filepath.Join(co.Dir(), "docs/new.txt-original")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("update backed up an uncovered file: %v", err) } if err := os.RemoveAll(filepath.Join(co.Dir(), "docs")); 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) } }