Artifact 8ae8d857e4fa63ae5a509be29bf513048e3b1b3e0e36d44d78c8eb0582e52cc1:
- File
internal/checkout/update_test.go
— part of check-in
[6e6969bf97]
at
2026-10-18 21:59:04
on branch main
— fix(checkout): keep uncommitted changes across Update, as fossil does (#450)
* fix(checkout): keep uncommitted changes across Update, as fossil does
Update compared only committed versions: it wrote the target over a locally edited file, deleted an edited file the target removed, and merged the current and target check-ins with their ancestor while ignoring the working file.
It now follows fossil's update (src/update.c): scan the checkout, then for each file take the target's content when there is no local change, merge a local edit three ways (the checkout's version as base, the working file as local, the target as other) with conflicts listed in Conflicted, keep an edited file the target deletes as an untracked file, keep pending adds and removals, restore a missing file, and copy an untracked file the target adds over to NAME-original first. A dry run writes nothing. Files written with the target's content record the mtime of the write. Files are matched by name; a target-side rename is not followed.
The two tests that relied on merging two branches' committed changes now make the clashing edit locally. A fossil 2.28 interop test runs the same edits through fossil update and compares files, fossil changes and Status.
Tracked in #443
* fix(checkout): make Update safe on binary files, failures and dry runs
A binary file (a NUL byte, or a line over 32,767 bytes, as fossil's line diff refuses) is no longer line-merged. Update saves the working copy as NAME-original and writes the target, as fossil's update does; Merge keeps the checkout's copy, as fossil's merge does. Both used to splice the versions and report a clean merge. merge.IsText is fossil's test.
Update refuses before writing anything when a file it must write or delete has a directory or a read-only file in the way, moves the checkout database to the target in one transaction, and in a dry run leaves the database untouched (the scan's findings stay in memory). Every target file gets the target's executable bit, as fossil's checkout_set_all_exe does, so a bit-only change reaches the disk.
UpdateResult.Unmerged lists files left to reconcile without markers: edited files the target deletes, files added both locally and by the target, and binary conflicts. The interop test now covers a binary conflict against fossil 2.28.
Tracked in #443
* fix(checkout): check real write access before Update writes anything
The pre-write check read the owner-write bit: it missed an unwritable directory (a remove failed after a merge had been written), files owned by others, and wrongly refused for root. It now asks the storage, via an optional CheckWritable: OSStorage uses access(2) with W_OK on unix and a read-only-attribute check elsewhere (js/wasm, Windows). Every step needs its directory writable; a write or merge over an existing file needs the file writable too.
A read-only untracked file where the target adds one is backed up to NAME-original, deleted and replaced, as fossil does, instead of refused.
Merge keeps a binary file whatever its strategy, as fossil's merge does. setVVar's precondition now catches a nil *sql.DB or *sql.Tx inside the interface.
Tracked in #443 (user: [email protected], size: 26592) [annotate] [blame] [check-ins using] [more...]
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) } // The write's own time, not the check-in's. if info.ModTime().Equal(time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)) { t.Fatalf("mtime set to the check-in time when SetMTime is false: %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 with uncommitted edits 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) } // Uncommitted edits that clash with branch B: fossil's update merges the // working files, so these are what conflict. for _, name := range []string{"/checkout/zeta.txt", "/checkout/alpha.txt"} { if err := mem.WriteFile(name, []byte("my edit\n"), 0o644); 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 fork — two checkins with the // same parent — checks out one with an uncommitted edit to the line the // other changed, 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) } // An uncommitted edit to conflict.txt. Update from leaf A to leaf B // merges it, with A as the base and B as the other side, as fossil's // update does, and the edits clash. if err := mem.WriteFile("/checkout/conflict.txt", []byte("my edit\n"), 0o644); err != nil { t.Fatal(err) } 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") } } // A dry-run Update leaves the checkout on its version (#236). func TestUpdateDryRunKeepsVersion(t *testing.T) { co, mem, rid1, rid2 := newCheckoutAtFirstOfTwo(t) if _, err := co.Update(UpdateOpts{TargetRID: rid2, DryRun: true}); err != nil { t.Fatalf("dry-run update: %v", err) } if vid := mustVersion(t, co); vid != rid1 { t.Fatalf("Version = %d after a dry-run update, want %d", vid, rid1) } data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello world\n" { t.Fatalf("hello.txt = %q after a dry-run update, want it untouched", data) } }