Artifact 76915f3ab320a8ac2634e1be4e5051c7f765eec234e759d3ca828ae48550dd5f:
- File
internal/checkout/update_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: 25394) [annotate] [blame] [check-ins using]
package checkout import ( "context" "errors" "reflect" "sort" "strconv" "strings" "testing" "time" 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" ) // newTestRepoWithTwoCheckins creates a repo with two checkins. // First checkin: hello.txt, src/main.go, README.md // Second checkin: hello.txt modified, src/main.go unchanged, README.md unchanged, new.txt added // Returns repo, rid1, rid2, cleanup. func newTestRepoWithTwoCheckins(t *testing.T) (*repo.Repo, libfossil.FslID, libfossil.FslID, func()) { t.Helper() dir := t.TempDir() path := dir + "/test.fossil" r, err := repo.CreateWithEnv(path, "test", simio.RealEnv(), "") if err != nil { t.Fatal(err) } // First checkin rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "hello.txt", Content: []byte("hello world\n")}, {Name: "src/main.go", Content: []byte("package main\n")}, {Name: "README.md", Content: []byte("# Test\n")}, }, Comment: "initial checkin", User: "test", Parent: 0, Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }) if err != nil { r.Close() t.Fatal(err) } // Second checkin — modify hello.txt, add new.txt rid2, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "hello.txt", Content: []byte("hello updated world\n")}, {Name: "src/main.go", Content: []byte("package main\n")}, {Name: "README.md", Content: []byte("# Test\n")}, {Name: "new.txt", Content: []byte("new file\n")}, }, Comment: "second checkin", User: "test", Parent: rid1, Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }) if err != nil { r.Close() t.Fatal(err) } return r, rid1, rid2, func() { r.Close() } } func TestCalcUpdateVersion(t *testing.T) { r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Checkout is at tip (rid2) after Create — CalcUpdateVersion should return 0. tip, err := co.CalcUpdateVersion() if err != nil { t.Fatal(err) } if tip != 0 { t.Fatalf("expected CalcUpdateVersion=0 at tip, got %d", tip) } // Set checkout to rid1 (the older version) if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid1).Scan(&uuid1); err != nil { t.Fatal(err) } if err := setVVar(co.db, "checkout-hash", uuid1); err != nil { t.Fatal(err) } // Now CalcUpdateVersion should return rid2 tip, err = co.CalcUpdateVersion() if err != nil { t.Fatal(err) } if tip != rid2 { t.Fatalf("CalcUpdateVersion = %d, want %d", tip, rid2) } } func TestUpdateLinear(t *testing.T) { r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Set checkout to rid1 if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid1).Scan(&uuid1); err != nil { t.Fatal(err) } if err := setVVar(co.db, "checkout-hash", uuid1); err != nil { t.Fatal(err) } // Extract rid1 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(rid1, ExtractOpts{}); err != nil { t.Fatal("extract rid1:", err) } // Verify initial state data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello world\n" { t.Fatalf("before update: hello.txt = %q", data) } // Track changes via callback var changes []struct { name string change UpdateChange } result, err := co.Update(UpdateOpts{ TargetRID: rid2, Callback: func(name string, change UpdateChange) error { changes = append(changes, struct { name string change UpdateChange }{name, change}) return nil }, }) if err != nil { t.Fatal("Update:", err) } // A clean update must report no conflicted paths, and must report the // paths it actually touched — not merely how many. if len(result.Conflicted) != 0 { t.Fatalf("expected no conflicts, got %v", result.Conflicted) } if !containsPath(result.FilesWritten, "hello.txt") { t.Fatalf("expected FilesWritten to contain hello.txt, got %v", result.FilesWritten) } if !containsPath(result.FilesWritten, "new.txt") { t.Fatalf("expected FilesWritten to contain new.txt, got %v", result.FilesWritten) } // Verify hello.txt was updated data, err = mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello updated world\n" { t.Fatalf("after update: hello.txt = %q", data) } // Verify new.txt was added data, err = mem.ReadFile("/checkout/new.txt") if err != nil { t.Fatal("new.txt not found:", err) } if string(data) != "new file\n" { t.Fatalf("new.txt = %q", data) } // Verify unchanged files still present data, err = mem.ReadFile("/checkout/src/main.go") if err != nil { t.Fatal(err) } if string(data) != "package main\n" { t.Fatalf("src/main.go = %q", data) } // Verify checkout version updated curRID, _, err := co.Version() if err != nil { t.Fatal(err) } if curRID != rid2 { t.Fatalf("after update: version RID = %d, want %d", curRID, rid2) } // Verify callbacks fired for changed files if len(changes) < 2 { t.Fatalf("expected at least 2 change callbacks, got %d", len(changes)) } // Check that we got an UpdateUpdated for hello.txt and UpdateAdded for new.txt foundUpdated := false foundAdded := false for _, ch := range changes { if ch.name == "hello.txt" && ch.change == UpdateUpdated { foundUpdated = true } if ch.name == "new.txt" && ch.change == UpdateAdded { foundAdded = true } } if !foundUpdated { t.Error("expected UpdateUpdated callback for hello.txt") } if !foundAdded { t.Error("expected UpdateAdded callback for new.txt") } } func TestUpdateNoChanges(t *testing.T) { r, _, _, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Checkout is already at tip — Update should be a no-op result, err := co.Update(UpdateOpts{}) if err != nil { t.Fatal("Update at tip should succeed:", err) } if len(result.Conflicted) != 0 || len(result.FilesWritten) != 0 || len(result.FilesRemoved) != 0 { t.Fatalf("expected empty result for no-op update, got %+v", result) } } func TestUpdateObserver(t *testing.T) { r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Set to rid1 if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid1).Scan(&uuid1); err != nil { t.Fatal(err) } if err := setVVar(co.db, "checkout-hash", uuid1); err != nil { t.Fatal(err) } mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract first so files exist if err := co.Extract(rid1, ExtractOpts{}); err != nil { t.Fatal(err) } // Install recording observer type event struct{ name string } var events []event obs := &testObserver{ onExtractStarted: func(ctx context.Context, e ExtractStart) context.Context { if e.Operation != "update" { t.Errorf("expected operation=update, got %s", e.Operation) } if e.TargetRID != rid2 { t.Errorf("expected target=%d, got %d", rid2, e.TargetRID) } 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) { if e.Operation != "update" { t.Errorf("expected operation=update, got %s", e.Operation) } events = append(events, event{"completed"}) }, } co.obs = obs if _, err := co.Update(UpdateOpts{TargetRID: rid2}); err != nil { t.Fatal(err) } if len(events) < 3 { // started + at least 1 file + completed t.Fatalf("expected at least 3 events, got %d: %v", len(events), events) } if events[0].name != "started" { t.Fatalf("first event = %s, want started", events[0].name) } if events[len(events)-1].name != "completed" { t.Fatalf("last event = %s, want completed", events[len(events)-1].name) } } func TestUpdateDryRun(t *testing.T) { r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Set to rid1 if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid1).Scan(&uuid1); err != nil { t.Fatal(err) } if err := setVVar(co.db, "checkout-hash", uuid1); err != nil { t.Fatal(err) } mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract rid1 files if err := co.Extract(rid1, ExtractOpts{}); err != nil { t.Fatal(err) } // DryRun update — should NOT modify files on disk var callbackCount int _, err = co.Update(UpdateOpts{ TargetRID: rid2, DryRun: true, Callback: func(name string, change UpdateChange) error { callbackCount++ return nil }, }) if err != nil { t.Fatal(err) } // hello.txt should still have old content (dry run) data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello world\n" { t.Fatalf("dry run should not modify hello.txt, got %q", data) } // new.txt should NOT exist (dry run) if _, err := mem.ReadFile("/checkout/new.txt"); err == nil { t.Fatal("dry run should not create new.txt") } // But callbacks should have fired if callbackCount == 0 { t.Fatal("expected callbacks during dry run") } } func TestUpdateWithFileRemoval(t *testing.T) { // Create a repo where the second checkin removes a file. dir := t.TempDir() path := dir + "/test.fossil" r, err := repo.CreateWithEnv(path, "test", simio.RealEnv(), "") if err != nil { t.Fatal(err) } defer r.Close() // First checkin: three files. rid1, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "keep.txt", Content: []byte("keep\n")}, {Name: "remove.txt", Content: []byte("bye\n")}, }, Comment: "initial", User: "test", Parent: 0, Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } // Second checkin: remove.txt omitted. rid2, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "keep.txt", Content: []byte("keep\n")}, }, Comment: "remove file", User: "test", Parent: rid1, Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } // Create checkout at rid1. ckDir := t.TempDir() co, err := Create(r, ckDir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Point checkout to rid1. if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string r.DB().QueryRow( "SELECT uuid FROM blob WHERE rid=?", rid1, ).Scan(&uuid1) if err := setVVar(co.db, "checkout-hash", uuid1); 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{Force: true}); err != nil { t.Fatal(err) } // Verify remove.txt exists before update. if _, err := mem.ReadFile("/checkout/remove.txt"); err != nil { t.Fatal("remove.txt should exist before update:", err) } // Update to rid2. result, err := co.Update(UpdateOpts{TargetRID: rid2}) if err != nil { t.Fatal(err) } if !containsPath(result.FilesRemoved, "remove.txt") { t.Fatalf("expected FilesRemoved to contain remove.txt, got %v", result.FilesRemoved) } // remove.txt should be deleted from Storage. if _, err := mem.ReadFile("/checkout/remove.txt"); err == nil { t.Fatal("remove.txt should be deleted after update") } // keep.txt should still exist. data, err := mem.ReadFile("/checkout/keep.txt") if err != nil { t.Fatal("keep.txt not found:", err) } if string(data) != "keep\n" { t.Fatalf("keep.txt = %q, want %q", data, "keep\n") } _ = rid2 // used above } func TestUpdateSetMTime(t *testing.T) { r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Set checkout to rid1. if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid1).Scan(&uuid1) if err := setVVar(co.db, "checkout-hash", uuid1); 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:", err) } // Update to rid2 with SetMTime. if _, err := co.Update(UpdateOpts{TargetRID: rid2, SetMTime: true}); err != nil { t.Fatal("update:", err) } // hello.txt was modified in rid2 — should have rid2's checkin mtime (2026-01-02). 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") } // rid2 timestamp is 2026-01-02. expected := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) if !mtime.Equal(expected) { t.Fatalf("hello.txt mtime = %v, want %v", mtime, expected) } // new.txt was added in rid2 — should also have rid2's mtime. info2, err := mem.Stat("/checkout/new.txt") if err != nil { t.Fatal("stat new.txt:", err) } if !info2.ModTime().Equal(expected) { t.Fatalf("new.txt mtime = %v, want %v", info2.ModTime(), expected) } } func TestUpdateSetMTimeFalse(t *testing.T) { r, rid1, rid2, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid1).Scan(&uuid1) setVVar(co.db, "checkout-hash", uuid1) mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" co.Extract(rid1, ExtractOpts{}) // Update WITHOUT SetMTime. if _, err := co.Update(UpdateOpts{TargetRID: rid2, SetMTime: false}); err != nil { t.Fatal("update:", err) } info, err := mem.Stat("/checkout/hello.txt") if err != nil { t.Fatal("stat:", err) } if !info.ModTime().IsZero() { t.Fatalf("mtime should be zero when SetMTime is false, got %v", info.ModTime()) } } // itoa is a helper to avoid importing strconv in tests. func itoa(n int64) string { return strconv.FormatInt(n, 10) } // containsPath reports whether paths contains name. func containsPath(paths []string, name string) bool { for _, p := range paths { if p == name { return true } } return false } // TestUpdateResultPathsAreSorted builds an update touching four files whose // names are deliberately not in the order the underlying map would build // them (allNames is a map[string]bool; Go randomizes iteration over it). // Two files conflict (alpha.txt, zeta.txt) and two are clean additions // (new_a.txt, new_z.txt). Every returned slice must come back sorted // regardless of iteration order, so a caller can use reflect.DeepEqual or a // golden file against the result instead of getting an intermittently // ordered list. func TestUpdateResultPathsAreSorted(t *testing.T) { dir := t.TempDir() path := dir + "/test.fossil" r, err := repo.CreateWithEnv(path, "test", simio.RealEnv(), "") if err != nil { t.Fatal(err) } defer r.Close() ridBase, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "zeta.txt", Content: []byte("original\n")}, {Name: "alpha.txt", Content: []byte("original\n")}, {Name: "stable.txt", Content: []byte("stable\n")}, }, Comment: "base", User: "test", Parent: 0, Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } ridA, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "zeta.txt", Content: []byte("local version\n")}, {Name: "alpha.txt", Content: []byte("local version\n")}, {Name: "stable.txt", Content: []byte("stable\n")}, }, Comment: "branch A", User: "test", Parent: ridBase, Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } // Branch B: conflicting edits to zeta.txt/alpha.txt, plus two brand-new // files that will be clean UpdateAdded entries in FilesWritten. ridB, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "zeta.txt", Content: []byte("remote version\n")}, {Name: "alpha.txt", Content: []byte("remote version\n")}, {Name: "stable.txt", Content: []byte("stable\n")}, {Name: "new_z.txt", Content: []byte("new\n")}, {Name: "new_a.txt", Content: []byte("new\n")}, }, Comment: "branch B", User: "test", Parent: ridBase, Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } ckDir := t.TempDir() co, err := Create(r, ckDir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() if err := setVVar(co.db, "checkout", itoa(int64(ridA))); err != nil { t.Fatal(err) } var uuidA string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", ridA).Scan(&uuidA); err != nil { t.Fatal(err) } if err := setVVar(co.db, "checkout-hash", uuidA); 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(ridA, ExtractOpts{}); err != nil { t.Fatal(err) } result, err := co.Update(UpdateOpts{TargetRID: ridB}) if err != nil { t.Fatalf("Update with conflicts should still succeed (err==nil): %v", err) } wantConflicted := []string{"alpha.txt", "zeta.txt"} if !reflect.DeepEqual(result.Conflicted, wantConflicted) { t.Fatalf("Conflicted = %v, want sorted %v", result.Conflicted, wantConflicted) } wantWritten := []string{"alpha.txt", "new_a.txt", "new_z.txt", "zeta.txt"} if !reflect.DeepEqual(result.FilesWritten, wantWritten) { t.Fatalf("FilesWritten = %v, want sorted %v", result.FilesWritten, wantWritten) } if !sort.StringsAreSorted(result.FilesWritten) { t.Fatalf("FilesWritten not sorted: %v", result.FilesWritten) } if !sort.StringsAreSorted(result.Conflicted) { t.Fatalf("Conflicted not sorted: %v", result.Conflicted) } } // TestUpdateConflictSurfacesPaths builds a genuine fork — two checkins with // the same parent, both editing the same line of the same file — and updates // across it. The three-way merge cannot resolve the overlapping edit cleanly, // so it writes conflict markers into conflict.txt. The update must still // report err == nil (this is a successful update — the working tree is // usable, just not clean) and must list conflict.txt in Conflicted. func TestUpdateConflictSurfacesPaths(t *testing.T) { dir := t.TempDir() path := dir + "/test.fossil" r, err := repo.CreateWithEnv(path, "test", simio.RealEnv(), "") if err != nil { t.Fatal(err) } defer r.Close() // Ancestor: base content both sides will diverge from. ridBase, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "conflict.txt", Content: []byte("original\n")}, {Name: "stable.txt", Content: []byte("stable\n")}, }, Comment: "base", User: "test", Parent: 0, Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } // Leaf A ("current"): edits conflict.txt one way. ridA, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "conflict.txt", Content: []byte("local version\n")}, {Name: "stable.txt", Content: []byte("stable\n")}, }, Comment: "branch A", User: "test", Parent: ridBase, Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } // Leaf B ("target"): edits conflict.txt a different, incompatible way. ridB, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "conflict.txt", Content: []byte("remote version\n")}, {Name: "stable.txt", Content: []byte("stable\n")}, }, Comment: "branch B", User: "test", Parent: ridBase, Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } ckDir := t.TempDir() co, err := Create(r, ckDir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() // Point the checkout at leaf A. if err := setVVar(co.db, "checkout", itoa(int64(ridA))); err != nil { t.Fatal(err) } var uuidA string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", ridA).Scan(&uuidA); err != nil { t.Fatal(err) } if err := setVVar(co.db, "checkout-hash", uuidA); 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(ridA, ExtractOpts{}); err != nil { t.Fatal(err) } // Update from leaf A to leaf B — this must 3-way merge conflict.txt // against the common ancestor (ridBase) and fail to merge cleanly. result, err := co.Update(UpdateOpts{TargetRID: ridB}) if err != nil { t.Fatalf("Update with conflict should still succeed (err==nil): %v", err) } if len(result.Conflicted) != 1 || result.Conflicted[0] != "conflict.txt" { t.Fatalf("expected Conflicted=[conflict.txt], got %v", result.Conflicted) } if !containsPath(result.FilesWritten, "conflict.txt") { t.Fatalf("expected FilesWritten to contain conflict.txt, got %v", result.FilesWritten) } // The conflicted file must actually contain conflict markers on disk — // that's the entire point of the Conflicted field. data, err := mem.ReadFile("/checkout/conflict.txt") if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "<<<<<<<") { t.Fatalf("expected conflict markers in conflict.txt, got %q", data) } } // TestUpdateFailureIsDistinguishableFromConflict confirms a genuine failure // (target checkin does not exist) returns a non-nil error, distinct from the // conflicted-but-successful case above. func TestUpdateFailureIsDistinguishableFromConflict(t *testing.T) { r, rid1, _, cleanup := newTestRepoWithTwoCheckins(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() if err := setVVar(co.db, "checkout", itoa(int64(rid1))); err != nil { t.Fatal(err) } var uuid1 string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid1).Scan(&uuid1); err != nil { t.Fatal(err) } if err := setVVar(co.db, "checkout-hash", uuid1); err != nil { t.Fatal(err) } _, err = co.Update(UpdateOpts{TargetRID: 999999}) if err == nil { t.Fatal("expected error for nonexistent target RID, got nil") } } // TestUpdateRefusesMissingContentBeforeWriting pins that Update checks the // target's content is all present before touching disk, as fossil's update // does (#231): otherwise it could write some files and then fail, leaving // disk on the target while the checkout still records the old version. func TestUpdateRefusesMissingContentBeforeWriting(t *testing.T) { co, mem, rid1, rid2 := newCheckoutAtFirstOfTwo(t) phantomize(t, co, rid2, "new.txt") _, err := co.Update(UpdateOpts{TargetRID: rid2}) if !errors.Is(err, errMissingContent) { t.Fatalf("Update = %v, want errMissingContent", 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, Update wrote before refusing", data) } if vid := mustVersion(t, co); vid != rid1 { t.Fatalf("Version = %d after refusal, want %d", vid, rid1) } } // A negative target is a caller's mistake, reported as an error rather than // a panic: it arrives through the public UpdateOpts.TargetRID. func TestUpdateRejectsNegativeTarget(t *testing.T) { co, _, _, _ := newCheckoutAtFirstOfTwo(t) // only the checkout matters here if _, err := co.Update(UpdateOpts{TargetRID: -1}); err == nil { t.Fatal("Update with a negative target succeeded") } }