Artifact f9771cde61ad9dcda375c20d906cd0522f43df90d853f9a343980945b4d5b4bf:
- File
internal/checkout/extract_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: 17116) [annotate] [blame] [check-ins using]
package checkout import ( "context" "errors" "strings" "testing" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/simio" ) func contains(s, substr string) bool { return strings.Contains(s, substr) } func TestExtract(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() // Use MemStorage to capture extracted files mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} // Use a virtual dir for extraction that works with MemStorage co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Verify files were written to MemStorage data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal("hello.txt not found:", err) } if string(data) != "hello world\n" { t.Fatalf("hello.txt = %q, want %q", data, "hello world\n") } data2, err := mem.ReadFile("/checkout/src/main.go") if err != nil { t.Fatal("src/main.go not found:", err) } if string(data2) != "package main\n" { t.Fatalf("src/main.go = %q", data2) } data3, err := mem.ReadFile("/checkout/README.md") if err != nil { t.Fatal("README.md not found:", err) } if string(data3) != "# Test\n" { t.Fatalf("README.md = %q", data3) } } func TestExtractDryRun(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" var callbackFiles []string err = co.Extract(rid, ExtractOpts{ DryRun: true, Callback: func(name string, change UpdateChange) error { callbackFiles = append(callbackFiles, name) return nil }, }) if err != nil { t.Fatal(err) } // Files should NOT be in MemStorage if _, err := mem.ReadFile("/checkout/hello.txt"); err == nil { t.Fatal("DryRun should not write files") } // But callback should have been called if len(callbackFiles) != 3 { t.Fatalf("expected 3 callback calls, got %d", len(callbackFiles)) } } func TestExtractForceProtection(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" // Extract files first. if err := co.Extract(rid, ExtractOpts{Force: true}); err != nil { t.Fatal(err) } // Modify hello.txt on disk so it differs from vfile mhash. if err := mem.WriteFile( "/checkout/hello.txt", []byte("local edit\n"), 0644, ); err != nil { t.Fatal(err) } // Extract again WITHOUT Force — should fail because hello.txt // has local changes. err = co.Extract(rid, ExtractOpts{Force: false}) if err == nil { t.Fatal("Extract without Force should fail over modified files") } if !contains(err.Error(), "local changes") { t.Fatalf("error should mention local changes, got: %v", err) } // Extract with Force=true should succeed. if err := co.Extract(rid, ExtractOpts{Force: true}); err != nil { t.Fatalf("Extract with Force should succeed: %v", err) } } func TestExtractSetMTime(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{SetMTime: true}); err != nil { t.Fatal(err) } // Verify mtime was set on extracted files. info, err := mem.Stat("/checkout/hello.txt") if err != nil { t.Fatal("stat hello.txt:", err) } mtime := info.ModTime() if mtime.IsZero() { t.Fatal("mtime should not be zero when SetMTime is true") } // The checkin timestamp should match what's in the event table. // Just verify it's a valid time (not zero) — the exact value depends // on the test repo's fixed timestamp. if mtime.Year() < 2020 { t.Fatalf("mtime %v looks invalid", mtime) } // Verify all extracted files got the same mtime. info2, _ := mem.Stat("/checkout/src/main.go") if !info2.ModTime().Equal(mtime) { t.Fatalf("src/main.go mtime %v != hello.txt mtime %v", info2.ModTime(), mtime) } } func TestExtractSetMTimeFalse(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{SetMTime: false}); err != nil { t.Fatal(err) } // Verify mtime was NOT set (should be zero in MemStorage). info, err := mem.Stat("/checkout/hello.txt") if err != nil { t.Fatal("stat hello.txt:", err) } if !info.ModTime().IsZero() { t.Fatalf("mtime should be zero when SetMTime is false, got %v", info.ModTime()) } } func TestExtractSetMTimeDryRun(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" // DryRun + SetMTime should not write files or set mtimes. if err := co.Extract(rid, ExtractOpts{SetMTime: true, DryRun: true}); err != nil { t.Fatal(err) } if _, err := mem.ReadFile("/checkout/hello.txt"); err == nil { t.Fatal("DryRun should not write files even with SetMTime") } } func TestExtractObserver(t *testing.T) { // Use a recording observer to verify hooks fire 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() type event struct{ name string } var events []event obs := &testObserver{ onExtractStarted: func(ctx context.Context, e ExtractStart) context.Context { events = append(events, event{"started"}) return ctx }, onExtractFileCompleted: func(ctx context.Context, name string, change UpdateChange) { events = append(events, event{"file:" + name}) }, onExtractCompleted: func(ctx context.Context, e ExtractEnd) { events = append(events, event{"completed"}) }, } co.obs = obs co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" co.Extract(rid, ExtractOpts{}) if len(events) < 5 { // started + 3 files + completed t.Fatalf("expected at least 5 events, got %d: %v", len(events), events) } if events[0].name != "started" { t.Fatalf("first event should be started, got %s", events[0].name) } if events[len(events)-1].name != "completed" { t.Fatalf("last event should be completed, got %s", events[len(events)-1].name) } } // newCheckoutAtFirstOfTwo builds a repo with two checkins (see // newTestRepoWithTwoCheckins: rid2 edits hello.txt and adds new.txt) and a // checkout with rid1 extracted into memory storage. func newCheckoutAtFirstOfTwo(t *testing.T) (co *Checkout, mem *simio.MemStorage, rid1, rid2 libfossil.FslID) { t.Helper() r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) t.Cleanup(cleanup) co, err := Create(r, t.TempDir(), CreateOpts{}) if err != nil { t.Fatal(err) } t.Cleanup(func() { co.Close() }) if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } mem = simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid1, ExtractOpts{}); err != nil { t.Fatal("extract rid1:", err) } return co, mem, rid1, rid2 } // changedNames scans the checkout's current version and returns the names // of its changed files. func changedNames(t *testing.T, co *Checkout) []string { t.Helper() vid, _, err := co.Version() if err != nil { t.Fatal(err) } var names []string err = co.VisitChanges(vid, true, func(e ChangeEntry) error { names = append(names, e.Name) return nil }) if err != nil { t.Fatal(err) } return names } // Switching a clean checkout to another version is what Extract is for: the // files that differ between the versions are not local changes (#230). func TestExtractSwitchesCleanCheckout(t *testing.T) { co, mem, _, rid2 := newCheckoutAtFirstOfTwo(t) if err := co.Extract(rid2, ExtractOpts{}); err != nil { t.Fatalf("Extract to the next version of a clean checkout: %v", err) } data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello updated world\n" { t.Fatalf("hello.txt = %q, want rid2's content", data) } if vid := mustVersion(t, co); vid != rid2 { t.Fatalf("Version = %d, want %d", vid, rid2) } } // A refused Extract must leave the checkout exactly as it was: same version, // and the unsaved edit still reported (#230). func TestExtractRefusalLeavesCheckoutUntouched(t *testing.T) { co, mem, rid1, rid2 := newCheckoutAtFirstOfTwo(t) if err := mem.WriteFile("/checkout/README.md", []byte("local edit\n"), 0o644); err != nil { t.Fatal(err) } err := co.Extract(rid2, ExtractOpts{}) if err == nil { t.Fatal("Extract over an unsaved edit succeeded") } if !contains(err.Error(), "local changes") { t.Fatalf("error should mention local changes, got: %v", err) } if vid := mustVersion(t, co); vid != rid1 { t.Fatalf("Version = %d after refusal, want %d", vid, rid1) } if got := changedNames(t, co); len(got) != 1 || got[0] != "README.md" { t.Fatalf("changes after refusal = %q, want only README.md", got) } } // A pending add is unsaved work too, even though the target version would // not write over the file. func TestExtractRefusesOverPendingAdd(t *testing.T) { co, mem, _, rid2 := newCheckoutAtFirstOfTwo(t) if err := mem.WriteFile("/checkout/extra.txt", []byte("extra\n"), 0o644); err != nil { t.Fatal(err) } if _, err := co.Manage(ManageOpts{Paths: []string{"extra.txt"}}); err != nil { t.Fatal(err) } if err := co.Extract(rid2, ExtractOpts{}); err == nil { t.Fatal("Extract over a pending add succeeded") } if got := changedNames(t, co); len(got) != 1 || got[0] != "extra.txt" { t.Fatalf("changes after refusal = %q, want only extra.txt", got) } } // A file the current version does not track, sitting where the target would // write different content, is the user's and must not be clobbered. func TestExtractRefusesOverUntrackedFile(t *testing.T) { co, mem, _, rid2 := newCheckoutAtFirstOfTwo(t) if err := mem.WriteFile("/checkout/new.txt", []byte("mine\n"), 0o644); err != nil { t.Fatal(err) } if err := co.Extract(rid2, ExtractOpts{}); err == nil { t.Fatal("Extract over an untracked file succeeded") } data, err := mem.ReadFile("/checkout/new.txt") if err != nil { t.Fatal(err) } if string(data) != "mine\n" { t.Fatalf("new.txt = %q, untracked file was overwritten", data) } } // Create records the tip as checked out before any file is written. Extracting // an older version into a directory that already holds it must still work: // the files differ from the tip, but none would lose content. This is how // EdgeSync's ExtractTo uses Create + Extract. func TestExtractReextractOlderVersionIntoCreatedCheckout(t *testing.T) { r, rid1, _, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() mem := simio.NewMemStorage() env := &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} for pass := 1; pass <= 2; pass++ { co, err := Create(r, t.TempDir(), CreateOpts{}) if err != nil { t.Fatal(err) } co.env = env co.dir = "/checkout" err = co.Extract(rid1, ExtractOpts{}) co.Close() if err != nil { t.Fatalf("pass %d: Extract(rid1) into a created checkout: %v", pass, err) } } data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello world\n" { t.Fatalf("hello.txt = %q, want rid1's content", data) } } // A pending merge (a vmerge row) is unsaved work even when every edited file // already matches the target: switching would drop it, and the stale vmerge // row would later give a commit a bogus merge parent. func TestExtractRefusesOverPendingMergeMatchingTarget(t *testing.T) { co, mem, _, rid2 := newCheckoutAtFirstOfTwo(t) if err := mem.WriteFile( "/checkout/hello.txt", []byte("hello updated world\n"), 0o644, ); err != nil { t.Fatal(err) } if _, err := co.db.Exec( "INSERT INTO vmerge(id, merge, mhash) VALUES(0, ?, 'x')", int64(rid2), ); err != nil { t.Fatal(err) } if err := co.Extract(rid2, ExtractOpts{}); err == nil { t.Fatal("Extract over a pending merge succeeded") } } // phantomize turns the stored content of name in version rid into a phantom, // the shape a partial sync leaves: the repo knows the artifact's hash but // holds no content for it. func phantomize(t *testing.T, co *Checkout, rid libfossil.FslID, name string) { t.Helper() files, err := manifest.ListFiles(co.repo, rid) if err != nil { t.Fatal(err) } for _, f := range files { if f.Name != name { continue } db := co.repo.DB() if _, err := db.Exec( "UPDATE blob SET size = -1, content = NULL WHERE uuid = ?", f.UUID, ); err != nil { t.Fatal(err) } if _, err := db.Exec( "INSERT OR IGNORE INTO phantom(rid) SELECT rid FROM blob WHERE uuid = ?", f.UUID, ); err != nil { t.Fatal(err) } return } t.Fatalf("%s is not in version %d", name, rid) } // Extracting a version whose content is partly missing must refuse up front // and leave the checkout as it was, as fossil's checkout does (#231). func TestExtractRefusesVersionWithMissingContent(t *testing.T) { co, mem, rid1, rid2 := newCheckoutAtFirstOfTwo(t) phantomize(t, co, rid2, "new.txt") err := co.Extract(rid2, ExtractOpts{}) if err == nil { t.Fatal("Extract of a version with missing content succeeded") } if !contains(err.Error(), "new.txt") { t.Fatalf("error should name the missing file, got: %v", err) } if vid := mustVersion(t, co); vid != rid1 { t.Fatalf("Version = %d after refusal, want %d", vid, rid1) } var rows int if err := co.db.QueryRow( "SELECT count(*) FROM vfile WHERE vid = ?", int64(rid2), ).Scan(&rows); err != nil { t.Fatal(err) } if rows != 0 { t.Fatalf("vfile holds %d rows for the refused version, want 0", rows) } data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello world\n" { t.Fatalf("hello.txt = %q, the refused extract wrote files", data) } } // A partly synced tip must not stop Create: the caller may be about to // extract an older, complete version (EdgeSync's ExtractTo does). func TestCreateWithPartialTipThenExtractCompleteVersion(t *testing.T) { r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() probe := &Checkout{repo: r} phantomize(t, probe, rid2, "new.txt") var observed []error co, err := Create(r, t.TempDir(), CreateOpts{Observer: &testObserver{ onError: func(_ context.Context, err error) { observed = append(observed, err) }, }}) if err != nil { t.Fatalf("Create with a partly synced tip: %v", err) } defer co.Close() if vid := mustVersion(t, co); vid != rid2 { t.Fatalf("Version = %d after Create, want the tip %d", vid, rid2) } if len(observed) != 1 || !errors.Is(observed[0], errMissingContent) { t.Fatalf("observer errors = %v, want one errMissingContent", observed) } var rows int if err := co.db.QueryRow("SELECT count(*) FROM vfile").Scan(&rows); err != nil { t.Fatal(err) } if rows != 0 { t.Fatalf("vfile holds %d rows for a tip it cannot load, want 0", rows) } co.env = &simio.Env{ Storage: simio.NewMemStorage(), Clock: simio.RealClock{}, Rand: simio.CryptoRand{}, } co.dir = "/checkout" if err := co.Extract(rid1, ExtractOpts{}); err != nil { t.Fatalf("Extract of the complete version: %v", err) } if err := co.db.QueryRow( "SELECT count(*) FROM vfile WHERE vid = ?", int64(rid1), ).Scan(&rows); err != nil { t.Fatal(err) } if rows != 3 { t.Fatalf("vfile holds %d rows for rid1 after Extract, want 3", rows) } } // mustVersion returns the checkout's current version, failing the test on // error. func mustVersion(t *testing.T, co *Checkout) libfossil.FslID { t.Helper() vid, _, err := co.Version() if err != nil { t.Fatal(err) } return vid }