Artifact b7e24ae993829c697d13ef7fe9da84ae2ec9ad7f953435f6690dbff64abde9c9:
- File
checkout_fossil_interop_test.go
— part of check-in
[0069100efe]
at
2026-10-03 15:20:47
on branch trunk
— fix(checkout): read checkouts made by the fossil binary (#228) (#229)
Status on a checkout created by `fossil open` failed with "converting NULL to string" on vfile.mhash. go-libfossil treated mhash as every file's baseline hash; fossil only fills it while a merge is pending (the hash of mrid when mrid != rid) and judges a file against the blob that vfile.mrid names. A NULL-tolerant scan alone would not have fixed it: with no baseline the comparison panics, and skipping it would hide real edits.
ScanChanges now compares each file with blob.uuid for vfile.mrid and moves chnged through fossil's own transitions (vfile_check_signature): a pending add (mrid=0) becomes 1, unchanged and clean-merge states (0, 2, 4) become 1 on a difference, a reverted edit returns to 0, and other merge states are kept. Comparing against the artifact's own uuid also picks the hash algorithm per file, independent of hash-policy.
The non-forced Extract guard read mhash with its error discarded, so on a fossil checkout it skipped the check and overwrote local edits. It now uses the same baseline, propagates read errors, and refuses while a merge is pending (mrid != rid or chnged > 1).
Status and HasChanges treat a rename as origname not in ('', pathname), since fossil stores origname == pathname on unrenamed rows, and HasChanges now sees `fossil mv`. A malformed row returns an error instead of being reported as ChangeNone.
LoadVFile and Manage stop writing mhash, and Manage writes islink, which fossil's vfile schema declares without a default.
Tests drive real fossil-made checkouts: clean status, edits and adds, a pending merge (status, preserved merge state, extract refusal), local edits under extract, fossil mv, and a go-libfossil Add read back by both tools. (user: [email protected], size: 7663) [annotate] [blame] [check-ins using]
package libfossil_test // Interop tests for checkouts created by the canonical fossil binary // (`fossil open`) and then driven through go-libfossil (#228). // // Fossil leaves vfile.mhash NULL unless a merge has given the file content // other than its checked-out version (mrid != rid); the baseline hash of an // ordinary file comes from blob.uuid for vfile.rid. These tests pin that // go-libfossil reads such a checkout the way fossil does. import ( "os" "os/exec" "path/filepath" "sort" "strings" "testing" libfossil "github.com/danmestas/go-libfossil" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/testutil" ) // fossilRun runs the fossil binary in dir and fails the test on error. func fossilRun(t *testing.T, bin, dir string, args ...string) string { t.Helper() cmd := exec.Command(bin, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } // newFossilCheckout creates a repo and a checkout of it with the fossil // binary, commits a.txt and b.txt, and returns the fossil binary, the repo // path, and the checkout directory. func newFossilCheckout(t *testing.T) (bin, repoPath, ckDir string) { t.Helper() bin = testutil.RequireFossilBin(t) dir := t.TempDir() repoPath = filepath.Join(dir, "test.fossil") ckDir = filepath.Join(dir, "ck") if err := os.Mkdir(ckDir, 0o755); err != nil { t.Fatal(err) } fossilRun(t, bin, dir, "init", repoPath) fossilRun(t, bin, ckDir, "open", repoPath) writeFile(t, filepath.Join(ckDir, "a.txt"), "one\n") writeFile(t, filepath.Join(ckDir, "b.txt"), "two\n") fossilRun(t, bin, ckDir, "add", "a.txt", "b.txt") fossilRun(t, bin, ckDir, "commit", "-m", "seed") return bin, repoPath, ckDir } // openLibfossilCheckout opens the fossil-made checkout through go-libfossil. // The repo and checkout are closed at test cleanup. func openLibfossilCheckout(t *testing.T, repoPath, ckDir string) *libfossil.Checkout { t.Helper() r, err := libfossil.Open(repoPath) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := r.Close(); err != nil { t.Errorf("close repo: %v", err) } }) ck, err := r.OpenCheckout(ckDir, libfossil.CheckoutOpenOpts{}) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := ck.Close(); err != nil { t.Errorf("close checkout: %v", err) } }) return ck } func statusLines(t *testing.T, ck *libfossil.Checkout) []string { t.Helper() changes, err := ck.Status() if err != nil { t.Fatalf("Status: %v", err) } var lines []string for _, c := range changes { lines = append(lines, c.Change+" "+c.Name) } sort.Strings(lines) return lines } func TestFossilCheckoutStatusClean(t *testing.T) { _, repoPath, ckDir := newFossilCheckout(t) ck := openLibfossilCheckout(t, repoPath, ckDir) if got := statusLines(t, ck); len(got) != 0 { t.Fatalf("clean fossil checkout: Status = %q, want none", got) } } func TestFossilCheckoutStatusReportsEdits(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) writeFile(t, filepath.Join(ckDir, "a.txt"), "one edited\n") writeFile(t, filepath.Join(ckDir, "c.txt"), "three\n") fossilRun(t, bin, ckDir, "add", "c.txt") ck := openLibfossilCheckout(t, repoPath, ckDir) got := strings.Join(statusLines(t, ck), ",") if want := "added c.txt,modified a.txt"; got != want { t.Fatalf("Status = %q, want %q", got, want) } } // A merge leaves vfile.mhash set and chnged=2 (UPDATED_BY_MERGE). Scanning // must judge the file against the merged-in hash, report it, and leave // fossil's merge state intact. func TestFossilCheckoutStatusDuringMerge(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) writeFile(t, filepath.Join(ckDir, "a.txt"), "one\nbranch\n") fossilRun(t, bin, ckDir, "commit", "-m", "branch", "--branch", "feat") fossilRun(t, bin, ckDir, "update", "trunk") fossilRun(t, bin, ckDir, "merge", "feat") ck := openLibfossilCheckout(t, repoPath, ckDir) got := strings.Join(statusLines(t, ck), ",") if want := "modified a.txt"; got != want { t.Fatalf("Status = %q, want %q", got, want) } changes := fossilRun(t, bin, ckDir, "changes") if !strings.Contains(changes, "UPDATED_BY_MERGE a.txt") { t.Fatalf("merge state lost after Status; fossil changes:\n%s", changes) } } // A non-forced Extract must refuse to clobber a local edit in a fossil-made // checkout, the same as it does in one go-libfossil created. func TestFossilCheckoutExtractKeepsLocalEdit(t *testing.T) { _, repoPath, ckDir := newFossilCheckout(t) aPath := filepath.Join(ckDir, "a.txt") writeFile(t, aPath, "local edit\n") ck := openLibfossilCheckout(t, repoPath, ckDir) rid, _, err := ck.Version() if err != nil { t.Fatal(err) } if err := ck.Extract(rid, libfossil.ExtractOpts{}); err == nil { t.Fatal("Extract over a local edit succeeded, want a local-changes error") } data, err := os.ReadFile(aPath) if err != nil { t.Fatal(err) } if string(data) != "local edit\n" { t.Fatalf("a.txt = %q, local edit was overwritten", data) } } // A non-forced Extract must not overwrite the result of a merge that fossil // has applied but not yet committed: the merged file has no local "edit" // relative to the merged-in artifact, but extracting the checked-out version // over it would silently drop the merge. func TestFossilCheckoutExtractKeepsPendingMerge(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) aPath := filepath.Join(ckDir, "a.txt") writeFile(t, aPath, "one\nbranch\n") fossilRun(t, bin, ckDir, "commit", "-m", "branch", "--branch", "feat") fossilRun(t, bin, ckDir, "update", "trunk") fossilRun(t, bin, ckDir, "merge", "feat") ck := openLibfossilCheckout(t, repoPath, ckDir) rid, _, err := ck.Version() if err != nil { t.Fatal(err) } if err := ck.Extract(rid, libfossil.ExtractOpts{}); err == nil { t.Fatal("Extract over a pending merge succeeded, want a pending-merge error") } data, err := os.ReadFile(aPath) if err != nil { t.Fatal(err) } if string(data) != "one\nbranch\n" { t.Fatalf("a.txt = %q, merge result was overwritten", data) } } // `fossil mv` records a rename in origname without touching chnged, so // HasChanges must see renames through origname, as Status does. func TestFossilCheckoutRename(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) fossilRun(t, bin, ckDir, "mv", "--hard", "a.txt", "z.txt") ck := openLibfossilCheckout(t, repoPath, ckDir) has, err := ck.HasChanges() if err != nil { t.Fatal(err) } if !has { t.Fatal("HasChanges = false after fossil mv, want true") } got := strings.Join(statusLines(t, ck), ",") if want := "renamed z.txt"; got != want { t.Fatalf("Status = %q, want %q", got, want) } } // Both tools working one checkout: a file added through go-libfossil must // read back cleanly in go-libfossil and show as ADDED in fossil. Fossil's // vfile schema gives islink no default, so a writer that omits it leaves a // NULL that broke Status. func TestFossilCheckoutLibfossilAdd(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) writeFile(t, filepath.Join(ckDir, "d.txt"), "four\n") ck := openLibfossilCheckout(t, repoPath, ckDir) added, err := ck.Add([]string{"d.txt"}) if err != nil { t.Fatal(err) } if added != 1 { t.Fatalf("Add = %d, want 1", added) } got := strings.Join(statusLines(t, ck), ",") if want := "added d.txt"; got != want { t.Fatalf("Status = %q, want %q", got, want) } changes := fossilRun(t, bin, ckDir, "changes") if !strings.Contains(changes, "ADDED d.txt") { t.Fatalf("fossil changes does not show the add:\n%s", changes) } }