Artifact cbc9c2537db418331d823ca7f4110dfda8f662d2396b9e04690e3eb511231356:
- File
internal/merge/merge_test.go
— part of check-in
[f804878bd3]
at
2026-07-22 23:58:46
on branch trunk
— test: route every fossil-binary gate through one REQUIRE_FOSSIL_BIN helper (#125)
* test: route fossil-binary tests through one REQUIRE_FOSSIL_BIN helper
REQUIRE_FOSSIL_BIN=1 turns a missing fossil binary into a test failure instead of an invisible skip, but only internal/manifest honored it. The six other fossil-dependent sites each did their own exec.LookPath + unconditional t.Skip, so on those a run with no fossil was byte-identical to a passing one.
Add testutil.RequireFossilBin, which resolves the binary the same way FossilBinary does (FOSSIL_BIN override, then PATH) and either skips or fails based on REQUIRE_FOSSIL_BIN. Route testutil.NewTestRepo, both internal/delta CLI round-trips, the four internal/merge validation tests, the internal/xfer sanity check, and the ls-branch-tip repro through it, deleting their bespoke lookup+skip logic. CI already sets REQUIRE_FOSSIL_BIN=1 on the unit-test steps, so this makes all seven sites fail loud on absence while local runs without fossil stay opt-in.
* test: route every fossil-binary gate through RequireFossilBin
The first pass left six sites still deciding for themselves whether a missing fossil binary is a skip or a failure. Three of them (internal/sync's startFossilServer and its two integration tests, manifest's two rebuild tests, the root TestPhaseB_Integration, and hash's TestSHA1_FossilValidation) skipped unconditionally, so with REQUIRE_FOSSIL_BIN=1 and no fossil on PATH those packages still reported ok -- the exact silent pass the flag exists to prevent. The other three had hand-rolled copies of the flag check, which is the duplication that let the original drift happen.
All of them now call testutil.RequireFossilBin, and HasFossil is gone: it was a second, skip-only way to ask the same question, and it is what produced the sites that ignored the flag.
Also validate the FOSSIL_BIN override with LookPath, so a stale path reports "fossil not found" instead of surfacing later as a raw fork/exec error.
* test: resolve fossil binary via RequireFossilBin at remaining call sites
* fix: correct stale module path missed by rebase auto-merge
Three imports added by earlier commits in this branch predated the rebase target and had no corresponding line on main to conflict against, so git's auto-merge carried the old module path through verbatim. (user: [email protected], size: 12147) [annotate] [blame] [check-ins using]
package merge import ( "os/exec" "path/filepath" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" 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/internal/testdriver" "github.com/danmestas/go-libfossil/simio" "github.com/danmestas/go-libfossil/testutil" ) // createDivergentRepo creates a repo with: // - Initial checkin: file.txt = baseContent // - Branch A checkin: file.txt = localContent (child of initial) // - Branch B checkin: file.txt = remoteContent (child of initial) // // Returns the repo, initial rid, branch A rid, branch B rid. func createDivergentRepo(t *testing.T, baseContent, localContent, remoteContent string) (*repo.Repo, libfossil.FslID, libfossil.FslID, libfossil.FslID) { t.Helper() path := filepath.Join(t.TempDir(), "test.fossil") r, err := repo.Create(path, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } // Initial checkin. baseRid, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "file.txt", Content: []byte(baseContent)}}, Comment: "initial", User: "testuser", Time: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("checkin base: %v", err) } // Branch A (local): child of initial. localRid, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "file.txt", Content: []byte(localContent)}}, Comment: "branch A", User: "testuser", Parent: baseRid, Time: time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("checkin local: %v", err) } // Branch B (remote): also child of initial (creates a fork). remoteRid, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "file.txt", Content: []byte(remoteContent)}}, Comment: "branch B", User: "testuser", Parent: baseRid, Time: time.Date(2026, 1, 3, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("checkin remote: %v", err) } return r, baseRid, localRid, remoteRid } // expandFileFromCheckin reads file.txt content from a checkin manifest. func expandFileFromCheckin(t *testing.T, r *repo.Repo, rid libfossil.FslID) []byte { t.Helper() files, err := manifest.ListFiles(r, rid) if err != nil { t.Fatalf("ListFiles: %v", err) } for _, f := range files { if f.Name == "file.txt" { frid, ok := blob.Exists(r.DB(), f.UUID) if !ok { t.Fatalf("blob %s not found", f.UUID) } data, err := content.Expand(r.DB(), frid) if err != nil { t.Fatalf("Expand: %v", err) } return data } } t.Fatal("file.txt not found in checkin") return nil } func TestFindCommonAncestorLinear(t *testing.T) { r, baseRid, localRid, _ := createDivergentRepo(t, "base\n", "local\n", "remote\n") defer r.Close() ancestor, err := FindCommonAncestor(r, localRid, baseRid) if err != nil { t.Fatal(err) } // localRid's parent is baseRid, so ancestor should be baseRid. if ancestor != baseRid { t.Fatalf("ancestor=%d, want %d (base)", ancestor, baseRid) } } func TestFindCommonAncestorForked(t *testing.T) { r, baseRid, localRid, remoteRid := createDivergentRepo(t, "base\n", "local\n", "remote\n") defer r.Close() ancestor, err := FindCommonAncestor(r, localRid, remoteRid) if err != nil { t.Fatal(err) } if ancestor != baseRid { t.Fatalf("ancestor=%d, want %d (base)", ancestor, baseRid) } } func TestDetectForksFindsTwo(t *testing.T) { r, _, _, _ := createDivergentRepo(t, "base\n", "local\n", "remote\n") defer r.Close() forks, err := DetectForks(r) if err != nil { t.Fatal(err) } if len(forks) == 0 { t.Fatal("expected at least one fork") } t.Logf("found %d fork(s): ancestor=%d local=%d remote=%d", len(forks), forks[0].Ancestor, forks[0].LocalTip, forks[0].RemoteTip) } func TestDetectForksLinear(t *testing.T) { path := filepath.Join(t.TempDir(), "linear.fossil") r, err := repo.Create(path, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatal(err) } defer r.Close() rid1, _, _ := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "a.txt", Content: []byte("v1\n")}}, Comment: "first", User: "testuser", Time: time.Now().UTC(), }) manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "a.txt", Content: []byte("v2\n")}}, Comment: "second", User: "testuser", Parent: rid1, Time: time.Now().UTC(), }) forks, err := DetectForks(r) if err != nil { t.Fatal(err) } if len(forks) != 0 { t.Fatalf("expected no forks in linear history, got %d", len(forks)) } } // --- Strategy tests with Fossil validation --- func TestThreeWayStrategyWithFossilValidation(t *testing.T) { fossilBin := testutil.RequireFossilBin(t) // Non-overlapping edits: local changes line 1, remote changes line 3. base := "line1\nline2\nline3\n" local := "LOCAL1\nline2\nline3\n" remote := "line1\nline2\nREMOTE3\n" r, baseRid, localRid, remoteRid := createDivergentRepo(t, base, local, remote) // Verify ancestor. ancestor, err := FindCommonAncestor(r, localRid, remoteRid) if err != nil { t.Fatal(err) } if ancestor != baseRid { t.Fatalf("ancestor=%d, want %d", ancestor, baseRid) } // Load content. baseContent := expandFileFromCheckin(t, r, baseRid) localContent := expandFileFromCheckin(t, r, localRid) remoteContent := expandFileFromCheckin(t, r, remoteRid) // Merge with three-way. strat := &ThreeWayText{} result, err := strat.Merge(baseContent, localContent, remoteContent) if err != nil { t.Fatal(err) } if !result.Clean { t.Fatalf("expected clean merge, got %d conflicts", len(result.Conflicts)) } expected := "LOCAL1\nline2\nREMOTE3\n" if string(result.Content) != expected { t.Fatalf("merged content = %q, want %q", result.Content, expected) } // Commit the merge result and verify with Fossil. mergeRid, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "file.txt", Content: result.Content}}, Comment: "merge of A and B", User: "testuser", Parent: localRid, Time: time.Now().UTC(), }) if err != nil { t.Fatalf("checkin merge result: %v", err) } _ = mergeRid r.Close() // Fossil rebuild — validates repo integrity. cmd := exec.Command(fossilBin, "rebuild", r.Path()) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil rebuild failed: %v\n%s", err, out) } t.Logf("fossil rebuild: %s", strings.TrimSpace(string(out))) // Fossil artifact — read the merged content back. r2, _ := repo.Open(r.Path()) defer r2.Close() mergedContent := expandFileFromCheckin(t, r2, mergeRid) if string(mergedContent) != expected { t.Fatalf("fossil-validated content = %q, want %q", mergedContent, expected) } t.Log("three-way merge: Fossil validation PASSED") } func TestLastWriterWinsWithFossilValidation(t *testing.T) { fossilBin := testutil.RequireFossilBin(t) base := "original content\n" local := "local edit\n" remote := "remote edit (newer)\n" r, _, localRid, _ := createDivergentRepo(t, base, local, remote) baseContent := []byte(base) localContent := []byte(local) remoteContent := []byte(remote) strat := &LastWriterWins{} result, err := strat.Merge(baseContent, localContent, remoteContent) if err != nil { t.Fatal(err) } if !result.Clean { t.Fatal("last-writer-wins should always be clean") } // Remote wins (passed as remote). if string(result.Content) != "remote edit (newer)\n" { t.Fatalf("content = %q", result.Content) } // Commit and validate. _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "file.txt", Content: result.Content}}, Comment: "last-writer-wins merge", User: "testuser", Parent: localRid, Time: time.Now().UTC(), }) if err != nil { t.Fatalf("checkin: %v", err) } r.Close() cmd := exec.Command(fossilBin, "rebuild", r.Path()) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil rebuild: %v\n%s", err, out) } t.Logf("fossil rebuild: %s", strings.TrimSpace(string(out))) t.Log("last-writer-wins: Fossil validation PASSED") } func TestBinaryStrategyWithFossilValidation(t *testing.T) { fossilBin := testutil.RequireFossilBin(t) base := "binary\x00base\xff" local := "binary\x00local\xff" remote := "binary\x00remote\xff" r, _, localRid, _ := createDivergentRepo(t, base, local, remote) strat := &Binary{} result, err := strat.Merge([]byte(base), []byte(local), []byte(remote)) if err != nil { t.Fatal(err) } if result.Clean { t.Fatal("binary should always conflict") } if len(result.Conflicts) != 1 { t.Fatalf("expected 1 conflict, got %d", len(result.Conflicts)) } // User picks local version to resolve. Commit it. _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "file.txt", Content: []byte(local)}}, Comment: "binary conflict resolved (kept local)", User: "testuser", Parent: localRid, Time: time.Now().UTC(), }) if err != nil { t.Fatalf("checkin: %v", err) } r.Close() cmd := exec.Command(fossilBin, "rebuild", r.Path()) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil rebuild: %v\n%s", err, out) } t.Logf("fossil rebuild: %s", strings.TrimSpace(string(out))) t.Log("binary strategy: Fossil validation PASSED") } func TestConflictForkWithFossilValidation(t *testing.T) { fossilBin := testutil.RequireFossilBin(t) base := "config: v1\n" local := "config: v2-local\n" remote := "config: v2-remote\n" r, baseRid, localRid, remoteRid := createDivergentRepo(t, base, local, remote) strat := &ConflictFork{} result, err := strat.Merge([]byte(base), []byte(local), []byte(remote)) if err != nil { t.Fatal(err) } if result.Clean { t.Fatal("conflict-fork should never be clean") } // Record in conflict table. if err := EnsureConflictTable(r); err != nil { t.Fatalf("EnsureConflictTable: %v", err) } if err := RecordConflictFork(r, "file.txt", int64(baseRid), int64(localRid), int64(remoteRid)); err != nil { t.Fatalf("RecordConflictFork: %v", err) } // Verify conflict table has the entry. forks, err := ListConflictForks(r) if err != nil { t.Fatalf("ListConflictForks: %v", err) } if len(forks) != 1 || forks[0] != "file.txt" { t.Fatalf("expected 1 fork for file.txt, got %v", forks) } // Resolve: user picks remote version. _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{{Name: "file.txt", Content: []byte(remote)}}, Comment: "conflict-fork resolved (picked remote)", User: "testuser", Parent: localRid, Time: time.Now().UTC(), }) if err != nil { t.Fatalf("checkin: %v", err) } if err := ResolveConflictFork(r, "file.txt"); err != nil { t.Fatalf("ResolveConflictFork: %v", err) } // Verify fork is resolved. forks, _ = ListConflictForks(r) if len(forks) != 0 { t.Fatalf("expected 0 forks after resolve, got %v", forks) } r.Close() cmd := exec.Command(fossilBin, "rebuild", r.Path()) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil rebuild: %v\n%s", err, out) } t.Logf("fossil rebuild: %s", strings.TrimSpace(string(out))) // Verify Fossil ignores the conflict table (it should rebuild fine). t.Log("conflict-fork: Fossil validation PASSED (conflict table ignored by fossil)") } func TestResolverPatternMatching(t *testing.T) { res := &Resolver{ patterns: []PatternRule{ {Glob: "*.yaml", Strategy: "last-writer-wins"}, {Glob: "*.png", Strategy: "binary"}, {Glob: "*.go", Strategy: "three-way"}, }, fallback: "three-way", } tests := []struct { file string expected string }{ {"config.yaml", "last-writer-wins"}, {"image.png", "binary"}, {"main.go", "three-way"}, {"readme.txt", "three-way"}, // fallback } for _, tt := range tests { got := res.Resolve(tt.file) if got != tt.expected { t.Errorf("Resolve(%q) = %q, want %q", tt.file, got, tt.expected) } } }