Artifact 7cb04984592756f50eb05aa499ee910f714e7e0e39dbf02e188940de461e48a8:
- File
checkout_fossil_interop_test.go
— part of check-in
[ff9c1af49d]
at
2026-10-03 16:28:59
on branch trunk
— fix(checkout): classify changes in one place; report missing files (#232) (#241)
* fix(checkout): classify changes in one place; report missing files (#232)
HasChanges, VisitChanges and Extract's safety check each decided what counts as a change: two SQL WHERE clauses and classifyChange, with an error path whose only job was catching their disagreement. They had already drifted once (HasChanges missed renames until #229).
Now one reader, loadVFileRows, returns every row of a version and one classifier, classifyChange, judges it, in the order fossil's changes command uses: removed > missing > added > renamed > modified. A renamed file that is also edited stays "renamed", so callers that see only the change type do not lose the rename. The WHERE clauses and the malformed-row error are gone.
VisitChanges with a scan now reports a tracked file that is gone from disk as ChangeMissing, which the public API already exposed as "missing" but nothing produced; such files were left out, or shown as added or modified. HasChanges sees a fossil add (rid=0, chnged=0) without a scan, since rid=0 now always means added (#231).
* fix(checkout): treat a non-file at a tracked path as missing (#232)
ScanChanges read each tracked path and failed the whole scan when a directory stood where a file belongs. Fossil's scan reports that path as NOT_A_FILE under its missing filter. The scan now stats first and treats anything that is not a regular file as missing, so Status reports it instead of erroring.
loadVFileRows orders rows by pathname, as fossil's changes lists them. The changelog notes that checkouts written before #231 may hold rid=0 placeholder rows, which HasChanges now counts. (user: [email protected], size: 11763) [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) } } // A tracked file gone from disk is reported as missing, as fossil's changes // command reports MISSING (#232): both a committed file deleted outside // fossil and a fossil add whose file was then deleted. func TestFossilCheckoutStatusReportsMissing(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) writeFile(t, filepath.Join(ckDir, "c.txt"), "three\n") fossilRun(t, bin, ckDir, "add", "c.txt") for _, name := range []string{"a.txt", "c.txt"} { if err := os.Remove(filepath.Join(ckDir, name)); err != nil { t.Fatal(err) } } changes := fossilRun(t, bin, ckDir, "changes") for _, want := range []string{"MISSING a.txt", "MISSING c.txt"} { if !strings.Contains(changes, want) { t.Fatalf("fossil changes lacks %q:\n%s", want, changes) } } ck := openLibfossilCheckout(t, repoPath, ckDir) got := strings.Join(statusLines(t, ck), ",") if want := "missing a.txt,missing c.txt"; got != want { t.Fatalf("Status = %q, want %q", got, want) } } // HasChanges sees a fossil add without a scan: fossil records the add as // rid=0 with chnged left 0, and rid=0 means added (#232). func TestFossilCheckoutHasChangesSeesAddWithoutScan(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) writeFile(t, filepath.Join(ckDir, "c.txt"), "three\n") fossilRun(t, bin, ckDir, "add", "c.txt") ck := openLibfossilCheckout(t, repoPath, ckDir) has, err := ck.HasChanges() if err != nil { t.Fatal(err) } if !has { t.Fatal("HasChanges = false with a pending fossil add, want true") } } // A directory where a tracked file belongs is not a file: fossil's changes // reports it as NOT_A_FILE, under its missing filter. Status reports it as // missing instead of failing (#232). func TestFossilCheckoutStatusDirectoryInPlaceOfFile(t *testing.T) { _, repoPath, ckDir := newFossilCheckout(t) aPath := filepath.Join(ckDir, "a.txt") if err := os.Remove(aPath); err != nil { t.Fatal(err) } if err := os.Mkdir(aPath, 0o755); err != nil { t.Fatal(err) } ck := openLibfossilCheckout(t, repoPath, ckDir) got := strings.Join(statusLines(t, ck), ",") if want := "missing a.txt"; got != want { t.Fatalf("Status = %q, want %q", got, want) } }