Artifact 70fca208c34fde5ebf7d879266319de31d93ed5660a08f9f77994e476b2450bd:
- File
checkout_fossil_interop_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: 9578) [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) } } // A version whose content is partly missing (a phantom, as a partial sync // leaves it) is refused for checkout by fossil and by go-libfossil alike // (#231). The test commits c.txt with fossil, turns its blob into a phantom, // then asks both tools to check that version out into a fresh directory. func TestMissingContentRefusedLikeFossil(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) writeFile(t, filepath.Join(ckDir, "c.txt"), "three\n") fossilRun(t, bin, ckDir, "add", "c.txt") fossilRun(t, bin, ckDir, "commit", "-m", "add c") fossilRun(t, bin, ckDir, "close", "--force") fossilRun(t, bin, filepath.Dir(repoPath), "sql", "-R", repoPath, "UPDATE blob SET size=-1, content=NULL WHERE rid=(SELECT fid FROM mlink "+ "JOIN filename USING(fnid) WHERE name='c.txt'); "+ "INSERT OR IGNORE INTO phantom(rid) SELECT rid FROM blob WHERE size<0;") fossilDir := t.TempDir() cmd := exec.Command(bin, "open", repoPath, "--workdir", fossilDir) cmd.Dir = fossilDir out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("fossil open succeeded on missing content:\n%s", out) } if !strings.Contains(string(out), "missing content") { t.Fatalf("fossil open failed for another reason:\n%s", out) } r, err := libfossil.Open(repoPath) if err != nil { t.Fatal(err) } defer r.Close() ck, err := r.CreateCheckout(t.TempDir(), libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatalf("CreateCheckout: %v", err) } defer ck.Close() tip, err := r.ResolveVersion("trunk") if err != nil { t.Fatal(err) } err = ck.Extract(tip, libfossil.ExtractOpts{}) if err == nil { t.Fatal("Extract of a version with missing content succeeded") } if !strings.Contains(err.Error(), "content missing") { t.Fatalf("Extract should refuse for missing content, got: %v", err) } if !strings.Contains(err.Error(), "c.txt") { t.Fatalf("Extract error should name c.txt, got: %v", err) } }