Artifact 1e4674ad2f984693f5e342433730955b6d85e8a37ffafcb5803d6fdb14d77e36:
- File
internal/checkout/vfile_test.go
— part of check-in
[a317896683]
at
2026-10-03 16:16:26
on branch trunk
— fix(checkout): refuse to load a version with missing content (#231) (#240)
* fix(checkout): refuse to load a version with missing content (#231)
LoadVFile wrote a vfile row with rid=0 for each file whose content the repo does not hold (a phantom, or a delta on one), as a partial sync leaves it. In fossil's vfile rid=0 means "added", so those rows were indistinguishable from pending adds, and Extract then panicked in content.Expand(0) trying to write them.
LoadVFile now does what fossil's checkout does: it resolves every file first and refuses the version, naming up to ten missing files and wrapping errMissingContent, before changing vfile at all. It no longer returns a missing count, which no caller used.
Create is the one caller that tolerates the refusal. A tip that a partial sync has not finished is no reason to fail CreateCheckout: the caller may be about to extract an older, complete version (EdgeSync's ExtractTo). vfile then stays empty instead of claiming files the repo cannot produce. Create's environment defaulting, shared with Open, moves to resolveEnv, and the initial load to loadInitialVFile.
Tests cover the refusal (vfile untouched, files named), Extract leaving the checkout as it was, Create on a partial tip followed by Extract of a complete version, and parity with the fossil binary, which refuses the same repository with "missing content".
* fix(checkout): refuse missing content in Update before writing (#231)
Update wrote changed files to disk and only then reloaded vfile, which now refuses a version with missing content. A target sharing a missing file with the current version (its content is never read, because it does not change) could therefore leave disk on the target while vvar and vfile stayed on the old version. Like fossil's update, Update now resolves the target's content before writing anything, through resolveFiles, the resolution step split out of LoadVFile.
Update moves its target selection into updateTarget, reuses Extract's checkinTime, and builds its result through sortedUpdateResult, bringing it under 70 lines. Create now reports a tip it could not load to the observer instead of tolerating it silently.
* fix(checkout): report Update refusals to observers; tighten checks (#231)
The missing-content check in Update moves after ExtractStarted, so a refused update still reaches observers as an ExtractCompleted carrying the error, as other Update failures do. A negative target from the public UpdateOpts is an error, not a panic. resolveFiles errors carry their own prefix instead of LoadVFile's.
Adds the entry and exit assertions the new helpers were missing, a compile-time check on missingNamesMax, and wraps lines over 100 columns; tests check Version's error instead of discarding it. (user: [email protected], size: 9208) [annotate] [blame] [check-ins using]
package checkout import ( "context" "errors" "strings" "testing" "github.com/danmestas/go-libfossil/simio" ) func TestLoadVFile(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() if err := co.LoadVFile(rid, true); err != nil { t.Fatal(err) } // Verify 3 rows in vfile var count int co.db.QueryRow("SELECT count(*) FROM vfile WHERE vid=?", int64(rid)).Scan(&count) if count != 3 { t.Fatalf("expected 3 vfile rows, got %d", count) } // Verify pathnames rows, _ := co.db.Query("SELECT pathname FROM vfile WHERE vid=? ORDER BY pathname", int64(rid)) defer rows.Close() var names []string for rows.Next() { var name string rows.Scan(&name) names = append(names, name) } expected := []string{"README.md", "hello.txt", "src/main.go"} if len(names) != len(expected) { t.Fatalf("expected %d names, got %d", len(expected), len(names)) } for i, n := range expected { if names[i] != n { t.Fatalf("vfile[%d] = %q, want %q", i, names[i], n) } } } func TestUnloadVFile(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() if err := co.LoadVFile(rid, true); err != nil { t.Fatal(err) } if err := co.UnloadVFile(rid); err != nil { t.Fatal(err) } var count int co.db.QueryRow("SELECT count(*) FROM vfile WHERE vid=?", int64(rid)).Scan(&count) if count != 0 { t.Fatalf("expected 0 vfile rows after unload, got %d", count) } } func TestLoadVFileClear(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() // Load vfile twice with different RIDs to test clear behavior // First load with clear=false if err := co.LoadVFile(rid, false); err != nil { t.Fatal(err) } // Manually insert a row with a different vid to test clear _, err = co.db.Exec(` INSERT INTO vfile(vid, pathname, rid, mrid, mhash, isexe, islink) VALUES(999, 'dummy.txt', 0, 0, 'dummy', 0, 0) `) if err != nil { t.Fatal(err) } // Verify we have rows for both vid=rid and vid=999 var countAll int co.db.QueryRow("SELECT count(*) FROM vfile").Scan(&countAll) if countAll != 4 { // 3 for rid + 1 for dummy t.Fatalf("expected 4 total rows before clear, got %d", countAll) } // Load with clear=true should remove the dummy row err = co.LoadVFile(rid, true) if err != nil { t.Fatal(err) } // Verify only rid rows remain var countRid int co.db.QueryRow("SELECT count(*) FROM vfile WHERE vid=?", int64(rid)).Scan(&countRid) if countRid != 3 { t.Fatalf("expected 3 rows for vid=%d after clear, got %d", rid, countRid) } var countDummy int co.db.QueryRow("SELECT count(*) FROM vfile WHERE vid=999").Scan(&countDummy) if countDummy != 0 { t.Fatalf("expected 0 rows for vid=999 after clear, got %d", countDummy) } } func TestLoadVFileRIDAndMRIDSet(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() if err := co.LoadVFile(rid, true); err != nil { t.Fatal(err) } // Verify that rid and mrid are both set and non-zero rows, _ := co.db.Query("SELECT pathname, rid, mrid FROM vfile WHERE vid=? ORDER BY pathname", int64(rid)) defer rows.Close() for rows.Next() { var name string var ridVal, mridVal int64 rows.Scan(&name, &ridVal, &mridVal) if ridVal == 0 { t.Errorf("file %s: rid is 0 (expected non-zero)", name) } if mridVal == 0 { t.Errorf("file %s: mrid is 0 (expected non-zero)", name) } if ridVal != mridVal { t.Errorf("file %s: rid=%d != mrid=%d (expected equal)", name, ridVal, mridVal) } } } func TestScanChangesModified(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() // Extract files to MemStorage mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Modify a file if err := mem.WriteFile("/checkout/hello.txt", []byte("modified content\n"), 0644); err != nil { t.Fatal(err) } // Scan if err := co.ScanChanges(ScanHash); err != nil { t.Fatal(err) } // Check vfile.chnged for hello.txt var chnged int if err := co.db.QueryRow("SELECT chnged FROM vfile WHERE vid=? AND pathname='hello.txt'", int64(rid)).Scan(&chnged); err != nil { t.Fatal(err) } if chnged != 1 { t.Fatalf("hello.txt chnged = %d, want 1", chnged) } // Unmodified files should remain chnged=0 if err := co.db.QueryRow("SELECT chnged FROM vfile WHERE vid=? AND pathname='README.md'", int64(rid)).Scan(&chnged); err != nil { t.Fatal(err) } if chnged != 0 { t.Fatalf("README.md chnged = %d, want 0", chnged) } } func TestScanChangesMissing(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Delete a file from MemStorage if err := mem.Remove("/checkout/hello.txt"); err != nil { t.Fatal(err) } // Scan should not error (missing files are noted, not fatal) if err := co.ScanChanges(ScanHash); err != nil { t.Fatal(err) } } func TestScanChangesNoHash(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{ Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}, } co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{Force: true}); err != nil { t.Fatal(err) } // Modify a file on disk. if err := mem.WriteFile( "/checkout/hello.txt", []byte("changed"), 0644, ); err != nil { t.Fatal(err) } // ScanChanges(0) — no ScanHash flag — should NOT detect content // changes (only mtime-based, which is not yet implemented, so // chnged should remain 0). if err := co.ScanChanges(0); err != nil { t.Fatal(err) } var chnged int err = co.db.QueryRow( "SELECT chnged FROM vfile WHERE vid=? AND pathname='hello.txt'", int64(rid), ).Scan(&chnged) if err != nil { t.Fatal(err) } if chnged != 0 { t.Fatalf("without ScanHash, chnged = %d, want 0", chnged) } } func TestScanChangesObserver(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() var scanEnd *ScanEnd co.obs = &testObserver{ onScanStarted: func(ctx context.Context) context.Context { return ctx }, onScanCompleted: func(ctx context.Context, e ScanEnd) { scanEnd = &e }, } co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } if err := mem.WriteFile("/checkout/hello.txt", []byte("changed"), 0644); err != nil { t.Fatal(err) } if err := co.ScanChanges(ScanHash); err != nil { t.Fatal(err) } if scanEnd == nil { t.Fatal("observer ScanCompleted not called") } if scanEnd.FilesScanned != 3 { t.Fatalf("scanned = %d, want 3", scanEnd.FilesScanned) } if scanEnd.FilesChanged != 1 { t.Fatalf("changed = %d, want 1", scanEnd.FilesChanged) } } // TestLoadVFileRefusesMissingContent pins that a version whose content is // partly missing is refused before vfile changes: the error names the file // and wraps errMissingContent, and the loaded version's rows survive even // with clear=true (#231). func TestLoadVFileRefusesMissingContent(t *testing.T) { co, _, rid1, rid2 := newCheckoutAtFirstOfTwo(t) phantomize(t, co, rid2, "new.txt") err := co.LoadVFile(rid2, true) if !errors.Is(err, errMissingContent) { t.Fatalf("LoadVFile = %v, want errMissingContent", err) } if !strings.Contains(err.Error(), "new.txt") { t.Fatalf("error should name new.txt, got: %v", err) } var rows1, rows2 int if err := co.db.QueryRow( "SELECT count(*) FILTER (WHERE vid = ?), count(*) FILTER (WHERE vid = ?) FROM vfile", int64(rid1), int64(rid2), ).Scan(&rows1, &rows2); err != nil { t.Fatal(err) } if rows1 != 3 { t.Fatalf("rid1 rows = %d after refusal, want 3", rows1) } if rows2 != 0 { t.Fatalf("rid2 rows = %d after refusal, want 0", rows2) } }