Artifact eab514bb61f6e4e56711ae78245762b81b9a38c522d1cc91fedf82e5cc09d3b6:
- File
checkout_update_test.go
— part of check-in
[75f3521e54]
at
2026-07-19 16:31:18
on branch trunk
— checkout: Update returns UpdateResult so conflicts are visible (#61)
* checkout: Update returns UpdateResult so conflicts are visible
The internal 3-way merge already tracked which files were written, removed, and left with conflict markers, but Checkout.Update discarded all of it and returned a bare error. That collapsed a clean update and one that silently wrote <<<<<<< markers into working-tree files into the same outcome, with no way for a caller to tell them apart.
Update now returns (UpdateResult, error). UpdateResult.Conflicted lists paths that were merged but not cleanly -- this is still a successful update (err == nil), not an error, since the working tree is usable and the caller just needs to know which files to show a user. A genuine failure still returns a non-nil error paired with a zero-value UpdateResult. FilesWritten/FilesRemoved are populated the same way, as paths rather than counts.
Threading the paths up required changing the internal update's processFileUpdates from returning bare int counts to returning the actual []string paths, since the count was all that existed before. Checkout.Extract is untouched -- it has no conflict concept.
Breaking change, clean break at v0.7.0 (pre-1.0).
* checkout: sort Update result paths, document FilesWritten/Conflicted overlap
Update's result slices were built by ranging over maps.allNames, a map[string]bool, so their order was whatever Go's randomized map iteration happened to produce that run. A caller diffing result.FilesWritten against a golden file, or asserting on it with reflect.DeepEqual, would see it fail intermittently for no reason related to their code.
Sort all three slices (sort.Strings) right before Update returns. Added a test that touches four files -- two conflicting, two clean additions, named so map iteration order is very unlikely to hand them back sorted by luck -- and asserts the exact sorted slice on both FilesWritten and Conflicted. Verified it fails reliably (5/5 runs) with the sort calls temporarily removed, then restored the fix.
Also stated outright, in both the internal and public UpdateResult doc comments, what "clean or not" was only implying: every path in Conflicted also appears in FilesWritten, since the conflict markers are what got written there. (user: [email protected], size: 7391) [annotate] [blame] [check-ins using]
package libfossil_test import ( "os" "path/filepath" "strings" "testing" "github.com/danmestas/libfossil" ) // updateFixture creates a repo, commits two revs via Repo.Commit, then // creates a checkout (initialized to tip = rid2). Returns the second RID // so callers can pass it as a deliberate Update target. func updateFixture(t *testing.T) (*libfossil.Repo, *libfossil.Checkout, int64) { t.Helper() dir := t.TempDir() repoPath := filepath.Join(dir, "u.fossil") repo, err := libfossil.Create(repoPath, libfossil.CreateOpts{}) if err != nil { t.Fatalf("Create: %v", err) } t.Cleanup(func() { _ = repo.Close() }) // First commit (no parent — genesis). rid1, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "first", User: "test", Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v1")}}, }) if err != nil { t.Fatalf("first Commit: %v", err) } // Second commit (parent = rid1). This becomes the tip. rid2, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "second", User: "test", ParentID: rid1, Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v2")}}, }) if err != nil { t.Fatalf("second Commit: %v", err) } // CreateCheckout (NOT OpenCheckout — the dir is fresh) initializes the // working tree to the tip checkin (rid2). checkoutDir := filepath.Join(dir, "wt") checkout, err := repo.CreateCheckout(checkoutDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatalf("CreateCheckout: %v", err) } t.Cleanup(func() { _ = checkout.Close() }) return repo, checkout, rid2 } func TestCheckoutUpdate_TargetRID(t *testing.T) { _, checkout, rid := updateFixture(t) result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: rid}) if err != nil { t.Fatalf("Update: %v", err) } if len(result.Conflicted) != 0 { t.Fatalf("expected clean update, got Conflicted=%v", result.Conflicted) } } func TestCheckoutUpdate_ZeroTargetRIDIsTipUpdate(t *testing.T) { // TargetRID=0 means "update to current branch tip" per checkout package. _, checkout, _ := updateFixture(t) result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: 0}) if err != nil { t.Fatalf("Update(0): %v", err) } if len(result.Conflicted) != 0 { t.Fatalf("expected clean update, got Conflicted=%v", result.Conflicted) } } func TestCheckoutUpdate_NonexistentRIDErrors(t *testing.T) { _, checkout, _ := updateFixture(t) result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: 999999}) if err == nil { t.Fatal("expected error for missing RID, got nil") } // A genuine failure must be distinguishable from a conflicted success: // no marker paths are reported for a failed update. if len(result.Conflicted) != 0 { t.Fatalf("expected empty Conflicted on failure, got %v", result.Conflicted) } } // TestCheckoutUpdate_CleanReportsWrittenPaths verifies the clean-success // outcome: err == nil, Conflicted empty, and the modified file's path shows // up in FilesWritten (not just a count). updateFixture's checkout starts at // tip (rid2), so this pins the checkout back to rid1 first via Extract to // force Update(TargetRID: rid2) to do real work. func TestCheckoutUpdate_CleanReportsWrittenPaths(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "clean.fossil") repo, err := libfossil.Create(repoPath, libfossil.CreateOpts{}) if err != nil { t.Fatalf("Create: %v", err) } t.Cleanup(func() { _ = repo.Close() }) rid1, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "first", User: "test", Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v1")}}, }) if err != nil { t.Fatalf("first Commit: %v", err) } rid2, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "second", User: "test", ParentID: rid1, Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v2")}}, }) if err != nil { t.Fatalf("second Commit: %v", err) } checkoutDir := filepath.Join(dir, "wt") checkout, err := repo.CreateCheckout(checkoutDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatalf("CreateCheckout: %v", err) } t.Cleanup(func() { _ = checkout.Close() }) // Pin the checkout to rid1 so Update(TargetRID: rid2) has real work to do. if err := checkout.Extract(rid1, libfossil.ExtractOpts{Force: true}); err != nil { t.Fatalf("Extract rid1: %v", err) } result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: rid2}) if err != nil { t.Fatalf("Update: %v", err) } if len(result.Conflicted) != 0 { t.Fatalf("expected clean update, got Conflicted=%v", result.Conflicted) } found := false for _, p := range result.FilesWritten { if p == "a.txt" { found = true } } if !found { t.Fatalf("expected FilesWritten to contain a.txt, got %v", result.FilesWritten) } } // TestCheckoutUpdate_ConflictedSurfacesPaths builds a genuine fork at the // public API level — two commits from the same parent, both editing the // same line — and updates across it. The merge cannot resolve the // overlapping edit, so it writes conflict markers into the working file. // This is a SUCCESSFUL update (err == nil): the caller must be able to see // which paths now contain markers via Conflicted. func TestCheckoutUpdate_ConflictedSurfacesPaths(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "c.fossil") repo, err := libfossil.Create(repoPath, libfossil.CreateOpts{}) if err != nil { t.Fatalf("Create: %v", err) } t.Cleanup(func() { _ = repo.Close() }) ridBase, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "base", User: "test", Files: []libfossil.FileToCommit{{Name: "conflict.txt", Content: []byte("original\n")}}, }) if err != nil { t.Fatalf("base Commit: %v", err) } ridA, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "branch A", User: "test", ParentID: ridBase, Files: []libfossil.FileToCommit{{Name: "conflict.txt", Content: []byte("local version\n")}}, }) if err != nil { t.Fatalf("branch A Commit: %v", err) } ridB, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "branch B", User: "test", ParentID: ridBase, Files: []libfossil.FileToCommit{{Name: "conflict.txt", Content: []byte("remote version\n")}}, }) if err != nil { t.Fatalf("branch B Commit: %v", err) } // CreateCheckout initializes to tip, which (last write wins in the // leaf table) is branch A here since it was committed first among the // two leaves and branch B follows — extract explicitly to branch A to // pin the starting point regardless of tip resolution. checkoutDir := filepath.Join(dir, "wt") checkout, err := repo.CreateCheckout(checkoutDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatalf("CreateCheckout: %v", err) } t.Cleanup(func() { _ = checkout.Close() }) if err := checkout.Extract(ridA, libfossil.ExtractOpts{Force: true}); err != nil { t.Fatalf("Extract ridA: %v", err) } result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: ridB}) if err != nil { t.Fatalf("conflicted Update must 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) } data, err := os.ReadFile(filepath.Join(checkoutDir, "conflict.txt")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "<<<<<<<") { t.Fatalf("expected conflict markers written to disk, got %q", data) } }