Artifact 3617baf0104e6e7d852c86433e62906f2e65e2fd9def0af38cbb611b35fadcf8:
- File
internal/checkout/status_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: 7688) [annotate] [blame] [check-ins using]
package checkout import ( "testing" "github.com/danmestas/go-libfossil/simio" ) func TestHasChangesClean(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } has, err := co.HasChanges() if err != nil { t.Fatal(err) } if has { t.Fatal("clean checkout should not have changes") } } func TestHasChangesModified(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Modify file and scan if err := mem.WriteFile("/checkout/hello.txt", []byte("changed"), 0644); err != nil { t.Fatal(err) } if err := co.ScanChanges(ScanHash); err != nil { t.Fatal(err) } has, err := co.HasChanges() if err != nil { t.Fatal(err) } if !has { t.Fatal("should have changes after modification") } } func TestVisitChanges(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Modify a file if err := mem.WriteFile("/checkout/hello.txt", []byte("changed"), 0644); err != nil { t.Fatal(err) } // VisitChanges with scan=true should detect the change var entries []ChangeEntry err = co.VisitChanges(rid, true, func(e ChangeEntry) error { entries = append(entries, e) return nil }) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("expected 1 change, got %d", len(entries)) } if entries[0].Name != "hello.txt" { t.Fatalf("changed file = %q, want hello.txt", entries[0].Name) } if entries[0].Change != ChangeModified { t.Fatalf("change type = %d, want ChangeModified", entries[0].Change) } } func TestVisitChangesNoScan(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Modify a file but don't scan if err := mem.WriteFile("/checkout/hello.txt", []byte("changed"), 0644); err != nil { t.Fatal(err) } // VisitChanges with scan=false should NOT detect the change var entries []ChangeEntry err = co.VisitChanges(rid, false, func(e ChangeEntry) error { entries = append(entries, e) return nil }) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Fatalf("expected 0 changes without scan, got %d", len(entries)) } } func TestVisitChangesMultiple(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Modify multiple files if err := mem.WriteFile("/checkout/hello.txt", []byte("changed1"), 0644); err != nil { t.Fatal(err) } if err := mem.WriteFile("/checkout/README.md", []byte("changed2"), 0644); err != nil { t.Fatal(err) } // VisitChanges should detect both changes var entries []ChangeEntry err = co.VisitChanges(rid, true, func(e ChangeEntry) error { entries = append(entries, e) return nil }) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("expected 2 changes, got %d", len(entries)) } // Check that both files are present found := make(map[string]bool) for _, e := range entries { if e.Change != ChangeModified { t.Errorf("file %s: expected ChangeModified, got %d", e.Name, e.Change) } found[e.Name] = true } if !found["hello.txt"] || !found["README.md"] { t.Fatal("expected hello.txt and README.md to be in changes") } } // TestVisitChangesFossilRowShapes pins how row shapes the fossil binary writes // are classified (#228, #232). It writes three rows straight into vfile, then // scans: // - new.txt: a fossil add (rid=0, chnged left 0) with the file on disk; // reports as added. // - gone.txt: a fossil add whose file was then deleted; reports as missing, // as fossil's changes command does. // - hello.txt: untouched, with origname equal to pathname; not a rename. func TestVisitChangesFossilRowShapes(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() co, err := Create(r, t.TempDir(), CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, err := co.Version() if err != nil { t.Fatal(err) } mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } if err := mem.WriteFile("/checkout/new.txt", []byte("new"), 0o644); err != nil { t.Fatal(err) } for _, stmt := range []string{ "UPDATE vfile SET origname = pathname WHERE vid = ?", "INSERT INTO vfile(vid, pathname, rid, mrid, chnged) VALUES(?, 'new.txt', 0, 0, 0)", "INSERT INTO vfile(vid, pathname, rid, mrid, chnged) VALUES(?, 'gone.txt', 0, 0, 0)", } { if _, err := co.db.Exec(stmt, int64(rid)); err != nil { t.Fatal(err) } } got := map[string]FileChange{} err = co.VisitChanges(rid, true, func(e ChangeEntry) error { got[e.Name] = e.Change return nil }) if err != nil { t.Fatal(err) } want := map[string]FileChange{"new.txt": ChangeAdded, "gone.txt": ChangeMissing} if len(got) != len(want) { t.Fatalf("visited %v, want %v", got, want) } for name, change := range want { if got[name] != change { t.Errorf("%s: change = %v, want %v", name, got[name], change) } } } // TestNextChangedState pins the chnged transitions against fossil's // vfile_check_signature, for a file present on disk. func TestNextChangedState(t *testing.T) { cases := []struct { chnged int64 hasBaseline bool differs bool want int64 }{ {0, false, false, 1}, // fossil add: always a change {1, false, false, 1}, {3, false, false, 3}, // added by merge: kept {0, true, false, 0}, {0, true, true, 1}, {1, true, false, 0}, // edit reverted by hand {1, true, true, 1}, {2, true, false, 2}, // updated by merge: kept while untouched {2, true, true, 1}, // ...and edited on top {4, true, true, 1}, {5, true, true, 5}, // merge conflict: kept {5, true, false, 5}, } for _, tc := range cases { got := nextChangedState(tc.chnged, tc.hasBaseline, tc.differs) if got != tc.want { t.Errorf("nextChangedState(%d, %v, %v) = %d, want %d", tc.chnged, tc.hasBaseline, tc.differs, got, tc.want) } } }