Artifact 6d03f37492f3f925b608d7ed8023359bfd4f2e936d139b17c72edd0f66c7d5b5:
- File
internal/manifest/mlink_test.go
— part of check-in
[9fed05730c]
at
2026-07-25 00:33:28
on branch trunk
— manifest: emit delete rows for omitted files and fix rename pid (#157) (#163)
insertCheckinMlinks walked only the child's F-cards, so two cases diverged from canonical Fossil's add_mlink:
1. A file present in the primary parent but dropped by OMISSION from a full (non-delta) child manifest got no mlink row at all. Canonical emits a delete row (fid=0) for it. A new second pass, insertOmittedDeletions, compares the primary parent's file set against the child's effective tree and emits a fid=0 delete row for every parent file the child no longer carries and named in no F-card.
2. A rename resolved its pid against the NEW path, which is absent from the parent, so pid defaulted to 0 as if the file were a fresh add -- losing pre-rename ancestry and defeating content_deltify. The F-card walk now resolves the parent lookup against the OLD path (OldName) for a rename, so pid is the parent's pre-rename blob rid.
Verified byte-for-byte against the fossil 2.28 binary (TestFossilBinaryMlinkParity_DeletionAndRename): canonical emits BOTH a rename row keyed on the new name (pfnid = old name) AND a delete row for the vacated old name, both carrying pid = the old-path blob rid. The omission pass reproduces that pair, and applyFileCard now vacates a rename's prior name from the effective tree so the old name is correctly seen as dropped in delta manifests too. (user: [email protected], size: 25689) [annotate] [blame] [check-ins using]
package manifest import ( "fmt" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" ) // mlinkRow mirrors one row of the mlink table for assertions below. type mlinkRow struct { fid, pmid, pid, fnid int64 } // TestPermToMperm_SubstringMatch pins permToMperm to canonical Fossil's // substring test (manifest_file_mperm, src/manifest.c:1482-1492), not an // exact-string match. "wx" is the case that matters in practice: // internal/deck/parse.go:194 assigns the F-card perm field verbatim from // remote xfer input, and canonical Fossil emits multi-character perm // fields (e.g. the " w" rename placeholder from #51). An exact match on // "wx" would silently drop the executable bit — the invariant #48 // protects. func TestPermToMperm_SubstringMatch(t *testing.T) { cases := []struct { perm string want int64 }{ {"", 0}, {"w", 0}, {"x", 1}, {"l", 2}, {"wx", 1}, // multi-character perm containing x: must still map to exec {"xl", 1}, // x wins over l when both present, matching canonical's check order {"lx", 1}, // order within the string must not matter {" w", 0}, // #51's rename placeholder: no x or l present } for _, c := range cases { if got := permToMperm(c.perm); got != c.want { t.Errorf("permToMperm(%q) = %d, want %d", c.perm, got, c.want) } } } // TestInsertCheckinMlinks_ThreeCasePidRule exercises libfossil#29's // acceptance criteria directly against the Crosslink (xfer ingestion) // write path: a merge commit whose F-cards cover all three pid cases from // canonical Fossil's add_mlink comment (src/manifest.c:1668-1679), plus the // fid=0 deletion case. // // - root.txt: deleted by the merge commit -> fid=0 // - on-branch.txt: absent from the primary parent (trunk) but present in // the merge parent (feature) -> pid=-1 // - merge-new.txt: absent from every parent -> pid=0 // - (implicitly) any file carried unchanged from the primary parent // resolves to pid = the parent's fid, exercised by every other // Crosslink test in this package. func TestInsertCheckinMlinks_ThreeCasePidRule(t *testing.T) { r := setupTestRepo(t) d := r.DB() // c1: trunk seed via the direct check-in path. rootContent := []byte("root content") trunkRid, trunkUUID, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "root.txt", Content: rootContent}}, Comment: "trunk seed", User: "tester", Time: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("trunk Checkin: %v", err) } // c2: feature branch off c1, adding on-branch.txt. onBranchContent := []byte("on-branch content") _, featureUUID, err := Checkin(r, CheckinOpts{ Files: []File{ {Name: "root.txt", Content: rootContent}, {Name: "on-branch.txt", Content: onBranchContent}, }, Parent: trunkRid, Comment: "feature adds on-branch.txt", User: "tester", Time: time.Date(2026, 5, 1, 13, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("feature Checkin: %v", err) } // c3: hand-built merge commit, ingested via Crosslink (the xfer path), // covering all three pid cases plus a deletion in one check-in: // - root.txt: deleted (empty UUID F-card) -> fid=0 // - on-branch.txt: unchanged from the merge parent -> pid=-1 // - merge-new.txt: brand new, in neither parent -> pid=0 onBranchUUID := hash.SHA1(onBranchContent) mergeNewContent := []byte("merge-new content") mergeNewUUID := hash.SHA1(mergeNewContent) if _, _, err := blob.Store(d, mergeNewContent); err != nil { t.Fatalf("blob.Store(merge-new.txt): %v", err) } mergeDeck := &deck.Deck{ Type: deck.Checkin, C: "merge feature into trunk", D: time.Date(2026, 5, 1, 14, 0, 0, 0, time.UTC), P: []string{trunkUUID, featureUUID}, // primary=trunk, merge=feature F: []deck.FileCard{ {Name: "root.txt"}, // deleted: no UUID {Name: "on-branch.txt", UUID: onBranchUUID}, {Name: "merge-new.txt", UUID: mergeNewUUID}, }, U: deck.User("tester"), } rHash, err := mergeDeck.ComputeR(func(uuid string) ([]byte, error) { switch uuid { case onBranchUUID: return onBranchContent, nil case mergeNewUUID: return mergeNewContent, nil default: return nil, fmt.Errorf("unexpected uuid: %s", uuid) } }) if err != nil { t.Fatalf("ComputeR: %v", err) } mergeDeck.R = rHash mergeBytes, err := mergeDeck.Marshal() if err != nil { t.Fatalf("Marshal merge: %v", err) } mergeRid, _, err := blob.Store(d, mergeBytes) if err != nil { t.Fatalf("blob.Store(merge): %v", err) } linked, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if linked != 1 { t.Fatalf("Crosslink linked = %d, want 1 (merge manifest only; trunk/feature already crosslinked by Checkin)", linked) } // rowFor takes the subtest's own *testing.T (not the parent's) so a // Fatalf in one subtest's lookup cannot abort its siblings: t.Run // subtests share the parent goroutine, and Fatalf unwinds via // runtime.Goexit, which stops the entire enclosing test function if the // closure captured the parent t instead of the subtest's. rowFor := func(t *testing.T, name string) mlinkRow { t.Helper() var row mlinkRow err := d.QueryRow( `SELECT m.fid, m.pmid, m.pid, m.fnid FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name=?`, mergeRid, name, ).Scan(&row.fid, &row.pmid, &row.pid, &row.fnid) if err != nil { t.Fatalf("mlink row for %q: %v", name, err) } return row } t.Run("deleted_file_gets_fid_zero", func(t *testing.T) { row := rowFor(t, "root.txt") if row.fid != 0 { t.Errorf("root.txt fid = %d, want 0 (deleted)", row.fid) } if row.pmid != int64(trunkRid) { t.Errorf("root.txt pmid = %d, want %d (primary parent)", row.pmid, trunkRid) } var trunkFid int64 if err := d.QueryRow( `SELECT m.fid FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name='root.txt'`, trunkRid, ).Scan(&trunkFid); err != nil { t.Fatalf("trunk root.txt fid lookup: %v", err) } if row.pid != trunkFid { t.Errorf("root.txt pid = %d, want %d (the primary parent's file rid)", row.pid, trunkFid) } }) t.Run("merge_added_file_gets_pid_negative_one", func(t *testing.T) { row := rowFor(t, "on-branch.txt") if row.pid != -1 { t.Errorf("on-branch.txt pid = %d, want -1 (added by merge)", row.pid) } if row.pmid != int64(trunkRid) { t.Errorf("on-branch.txt pmid = %d, want %d (primary parent, even though pid resolves via the merge parent)", row.pmid, trunkRid) } if row.fid == 0 { t.Errorf("on-branch.txt fid = 0, want the merge commit's file rid") } }) t.Run("normal_added_file_gets_pid_zero", func(t *testing.T) { row := rowFor(t, "merge-new.txt") if row.pid != 0 { t.Errorf("merge-new.txt pid = %d, want 0 (new to this check-in, absent from every parent)", row.pid) } if row.pmid != int64(trunkRid) { t.Errorf("merge-new.txt pmid = %d, want %d (primary parent)", row.pmid, trunkRid) } if row.fid == 0 { t.Errorf("merge-new.txt fid = 0, want the merge commit's file rid") } }) } // mustMarshalManifest computes a manifest's R-card over blobs and marshals it // to its on-disk bytes, failing the test on any error. blobs maps every file // UUID the R-card walks to its content. func mustMarshalManifest(t *testing.T, d *deck.Deck, blobs map[string][]byte) []byte { t.Helper() rHash, err := d.ComputeR(func(uuid string) ([]byte, error) { if c, ok := blobs[uuid]; ok { return c, nil } return nil, fmt.Errorf("unexpected uuid: %s", uuid) }) if err != nil { t.Fatalf("ComputeR: %v", err) } d.R = rHash b, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } return b } // TestInsertCheckinMlinks_DiffsAgainstParentManifest pins the #89 mlink // correctness fix on the Crosslink (xfer ingestion) write path: an mlink row // must be emitted only for a file that CHANGED relative to its primary // parent's manifest -- added, modified, deleted, or renamed -- not one row per // F-card. pmid/pid must be populated from the parent MANIFEST, so pid is // correct even when the parent check-in has not been crosslinked yet, which is // routine under the delta-chain visiting order that crosslinks a child before // its parent. // // Methodology: hand-build a parent baseline manifest {a.txt=A, b.txt=B} and a // child {a.txt=A (unchanged), b.txt=B2 (modified)} whose P-card names the // parent. Store the CHILD manifest blob FIRST so it takes the lower rid and is // visited before the parent in the same Crosslink sweep -- the exact timing // under which the old parent-mlink lookup returned an empty set and defaulted // pid to 0. Assert the child's mlink holds exactly one row (b.txt), none for // the unchanged a.txt, and that b.txt's pmid/pid name the parent manifest and // the parent's b.txt blob. func TestInsertCheckinMlinks_DiffsAgainstParentManifest(t *testing.T) { r := setupTestRepo(t) dbq := r.DB() aContent := []byte("a content") bContent := []byte("b content v1") b2Content := []byte("b content v2") aUUID := hash.SHA1(aContent) bUUID := hash.SHA1(bContent) b2UUID := hash.SHA1(b2Content) // Store every file blob up front so Crosslink never defers. if _, _, err := blob.Store(dbq, aContent); err != nil { t.Fatalf("store a: %v", err) } bRid, _, err := blob.Store(dbq, bContent) if err != nil { t.Fatalf("store b: %v", err) } b2Rid, _, err := blob.Store(dbq, b2Content) if err != nil { t.Fatalf("store b2: %v", err) } trunkTags := []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, } // Parent baseline manifest: {a.txt=A, b.txt=B}. parent := &deck.Deck{ Type: deck.Checkin, C: "parent", D: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), F: []deck.FileCard{{Name: "a.txt", UUID: aUUID}, {Name: "b.txt", UUID: bUUID}}, U: deck.User("tester"), T: trunkTags, } parentBytes := mustMarshalManifest(t, parent, map[string][]byte{aUUID: aContent, bUUID: bContent}) parentUUID := hash.SHA1(parentBytes) // Child baseline manifest atop the parent: a.txt unchanged, b.txt modified. child := &deck.Deck{ Type: deck.Checkin, C: "child modifies b.txt", D: time.Date(2026, 6, 1, 13, 0, 0, 0, time.UTC), P: []string{parentUUID}, F: []deck.FileCard{{Name: "a.txt", UUID: aUUID}, {Name: "b.txt", UUID: b2UUID}}, U: deck.User("tester"), } childBytes := mustMarshalManifest(t, child, map[string][]byte{aUUID: aContent, b2UUID: b2Content}) // Store the CHILD blob before the PARENT blob so the child gets the lower // rid and is crosslinked first -- reproducing the parent-not-yet-linked // timing that made the old code default pid to 0. childRid, _, err := blob.Store(dbq, childBytes) if err != nil { t.Fatalf("store child: %v", err) } parentRid, _, err := blob.Store(dbq, parentBytes) if err != nil { t.Fatalf("store parent: %v", err) } linked, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if linked != 2 { t.Fatalf("Crosslink linked = %d, want 2 (parent + child)", linked) } // The child must have exactly one mlink row: b.txt (modified). a.txt is // unchanged from the parent and must not appear. var childMlinkCount int if err := dbq.QueryRow("SELECT count(*) FROM mlink WHERE mid=?", childRid).Scan(&childMlinkCount); err != nil { t.Fatalf("count child mlink: %v", err) } if childMlinkCount != 1 { t.Errorf("child mlink count = %d, want 1 (only the modified b.txt)", childMlinkCount) } var aCount int if err := dbq.QueryRow( `SELECT count(*) FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name='a.txt'`, childRid).Scan(&aCount); err != nil { t.Fatalf("count a.txt mlink: %v", err) } if aCount != 0 { t.Errorf("a.txt mlink rows = %d, want 0 (unchanged from parent)", aCount) } var row mlinkRow if err := dbq.QueryRow( `SELECT m.fid, m.pmid, m.pid, m.fnid FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name='b.txt'`, childRid).Scan(&row.fid, &row.pmid, &row.pid, &row.fnid); err != nil { t.Fatalf("b.txt mlink row: %v", err) } if row.fid != int64(b2Rid) { t.Errorf("b.txt fid = %d, want %d (child's new blob)", row.fid, b2Rid) } if row.pmid != int64(parentRid) { t.Errorf("b.txt pmid = %d, want %d (primary parent manifest)", row.pmid, parentRid) } if row.pid != int64(bRid) { t.Errorf("b.txt pid = %d, want %d (parent's b.txt blob); pid must come from the parent manifest, not default to 0", row.pid, bRid) } } // TestInsertCheckinMlinks_FullManifestDeletionByOmission pins issue #157 gap 1: // a file present in the primary parent but simply ABSENT from a full (non-delta) // child manifest -- deleted by omission, with no empty-UUID F-card -- must still // get a delete mlink row (fid=0) whose pid names the parent's blob, matching // canonical Fossil's add_mlink. The prior code walked only the child's F-cards, // so an omitted file produced no row at all. // // Methodology: hand-build a full parent {a.txt=A, b.txt=B} and a full child // {a.txt=A} that drops b.txt by omission (b.txt appears in NO F-card, empty or // otherwise). Store the child blob first so it takes the lower rid and is // crosslinked before its parent -- the same delta-chain timing #89 exercised -- // then assert the child holds exactly one mlink row: b.txt with fid=0, pmid the // parent manifest, pid the parent's b.txt blob, and no pfnid. func TestInsertCheckinMlinks_FullManifestDeletionByOmission(t *testing.T) { r := setupTestRepo(t) dbq := r.DB() aContent := []byte("a content") bContent := []byte("b content") aUUID := hash.SHA1(aContent) bUUID := hash.SHA1(bContent) if _, _, err := blob.Store(dbq, aContent); err != nil { t.Fatalf("store a: %v", err) } bRid, _, err := blob.Store(dbq, bContent) if err != nil { t.Fatalf("store b: %v", err) } trunkTags := []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, } // Parent full manifest: {a.txt=A, b.txt=B}. parent := &deck.Deck{ Type: deck.Checkin, C: "parent", D: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), F: []deck.FileCard{{Name: "a.txt", UUID: aUUID}, {Name: "b.txt", UUID: bUUID}}, U: deck.User("tester"), T: trunkTags, } parentBytes := mustMarshalManifest(t, parent, map[string][]byte{aUUID: aContent, bUUID: bContent}) parentUUID := hash.SHA1(parentBytes) // Child full manifest: {a.txt=A}. b.txt is dropped by OMISSION -- there is // no F-card for it at all, not even an empty-UUID deletion card. child := &deck.Deck{ Type: deck.Checkin, C: "child drops b.txt by omission", D: time.Date(2026, 6, 1, 13, 0, 0, 0, time.UTC), P: []string{parentUUID}, F: []deck.FileCard{{Name: "a.txt", UUID: aUUID}}, U: deck.User("tester"), } childBytes := mustMarshalManifest(t, child, map[string][]byte{aUUID: aContent}) // Store the CHILD before the PARENT so the child is crosslinked first -- // pid must still come from the parent MANIFEST, not the not-yet-written // parent mlink rows. childRid, _, err := blob.Store(dbq, childBytes) if err != nil { t.Fatalf("store child: %v", err) } parentRid, _, err := blob.Store(dbq, parentBytes) if err != nil { t.Fatalf("store parent: %v", err) } linked, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if linked != 2 { t.Fatalf("Crosslink linked = %d, want 2 (parent + child)", linked) } // The child must have exactly one mlink row: the b.txt deletion. a.txt is // carried over unchanged and must not appear. var childMlinkCount int if err := dbq.QueryRow("SELECT count(*) FROM mlink WHERE mid=?", childRid).Scan(&childMlinkCount); err != nil { t.Fatalf("count child mlink: %v", err) } if childMlinkCount != 1 { t.Errorf("child mlink count = %d, want 1 (only the b.txt deletion by omission)", childMlinkCount) } var aCount int if err := dbq.QueryRow( `SELECT count(*) FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name='a.txt'`, childRid).Scan(&aCount); err != nil { t.Fatalf("count a.txt mlink: %v", err) } if aCount != 0 { t.Errorf("a.txt mlink rows = %d, want 0 (carried over unchanged)", aCount) } var row mlinkRow var pfnid int64 if err := dbq.QueryRow( `SELECT m.fid, m.pmid, m.pid, m.fnid, coalesce(m.pfnid,0) FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name='b.txt'`, childRid).Scan(&row.fid, &row.pmid, &row.pid, &row.fnid, &pfnid); err != nil { t.Fatalf("b.txt mlink row: %v", err) } if row.fid != 0 { t.Errorf("b.txt fid = %d, want 0 (deleted by omission)", row.fid) } if row.pmid != int64(parentRid) { t.Errorf("b.txt pmid = %d, want %d (primary parent manifest)", row.pmid, parentRid) } if row.pid != int64(bRid) { t.Errorf("b.txt pid = %d, want %d (parent's b.txt blob rid)", row.pid, bRid) } if pfnid != 0 { t.Errorf("b.txt pfnid = %d, want 0 (a deletion, not a rename)", pfnid) } } // TestInsertCheckinMlinks_RenameResolvesPidFromOldPath pins issue #157 gap 2: // the rename row (keyed on the new name) must resolve its pid from the parent's // blob at the OLD path, not default to 0 as a fresh add would. The fixture // renames-with-edit so pid (old blob) and fid (new blob) are distinct rids, // making the "pid comes from the old path" claim unambiguous. // // Canonical Fossil emits TWO rows for a rename, confirmed against the fossil // binary in TestFossilBinaryMlinkParity_DeletionAndRename: the rename row under // the new name (pfnid = old name) and a delete row (fid=0) for the vacated old // name. Both carry pid = the parent's old-path blob rid. // // Methodology: parent full {old.txt=X}; child full renames old.txt -> new.txt // while editing its content to Y, as a single F-card {Name:new.txt, UUID:Y, // OldName:old.txt}. Assert both rows: new.txt with fid=Y, pid=X, pfnid=old.txt; // and old.txt with fid=0, pid=X, no pfnid. func TestInsertCheckinMlinks_RenameResolvesPidFromOldPath(t *testing.T) { r := setupTestRepo(t) dbq := r.DB() xContent := []byte("original content") yContent := []byte("edited content after rename") xUUID := hash.SHA1(xContent) yUUID := hash.SHA1(yContent) xRid, _, err := blob.Store(dbq, xContent) if err != nil { t.Fatalf("store x: %v", err) } yRid, _, err := blob.Store(dbq, yContent) if err != nil { t.Fatalf("store y: %v", err) } trunkTags := []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, } // Parent full manifest: {old.txt=X}. parent := &deck.Deck{ Type: deck.Checkin, C: "parent", D: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), F: []deck.FileCard{{Name: "old.txt", UUID: xUUID}}, U: deck.User("tester"), T: trunkTags, } parentBytes := mustMarshalManifest(t, parent, map[string][]byte{xUUID: xContent}) parentUUID := hash.SHA1(parentBytes) // Child full manifest: old.txt renamed to new.txt AND edited to Y, as one // F-card carrying the prior name. child := &deck.Deck{ Type: deck.Checkin, C: "child renames and edits old.txt", D: time.Date(2026, 6, 1, 13, 0, 0, 0, time.UTC), P: []string{parentUUID}, F: []deck.FileCard{{Name: "new.txt", UUID: yUUID, OldName: "old.txt"}}, U: deck.User("tester"), } childBytes := mustMarshalManifest(t, child, map[string][]byte{yUUID: yContent}) childRid, _, err := blob.Store(dbq, childBytes) if err != nil { t.Fatalf("store child: %v", err) } if _, _, err := blob.Store(dbq, parentBytes); err != nil { t.Fatalf("store parent: %v", err) } linked, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if linked != 2 { t.Fatalf("Crosslink linked = %d, want 2 (parent + child)", linked) } // Two rows: the rename (new.txt) and the delete of the vacated old.txt. var childMlinkCount int if err := dbq.QueryRow("SELECT count(*) FROM mlink WHERE mid=?", childRid).Scan(&childMlinkCount); err != nil { t.Fatalf("count child mlink: %v", err) } if childMlinkCount != 2 { t.Errorf("child mlink count = %d, want 2 (the rename row plus the old-name delete)", childMlinkCount) } var oldFnid int64 if err := dbq.QueryRow(`SELECT fnid FROM filename WHERE name='old.txt'`).Scan(&oldFnid); err != nil { t.Fatalf("old.txt fnid lookup: %v", err) } // The rename row, keyed on the new name: fid=Y, pid=X (old path), pfnid=old. var renameRow mlinkRow var renamePfnid int64 if err := dbq.QueryRow( `SELECT m.fid, m.pid, m.fnid, coalesce(m.pfnid,0) FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name='new.txt'`, childRid).Scan(&renameRow.fid, &renameRow.pid, &renameRow.fnid, &renamePfnid); err != nil { t.Fatalf("new.txt mlink row: %v", err) } if renameRow.fid != int64(yRid) { t.Errorf("new.txt fid = %d, want %d (child's new blob)", renameRow.fid, yRid) } if renameRow.pid != int64(xRid) { t.Errorf("new.txt pid = %d, want %d (parent's OLD-path blob rid, not 0)", renameRow.pid, xRid) } if renamePfnid != oldFnid { t.Errorf("new.txt pfnid = %d, want %d (old.txt's fnid)", renamePfnid, oldFnid) } // The delete row for the vacated old name: fid=0, pid=X, no pfnid. var delRow mlinkRow var delPfnid int64 if err := dbq.QueryRow( `SELECT m.fid, m.pid, m.fnid, coalesce(m.pfnid,0) FROM mlink m WHERE m.mid=? AND m.fnid=?`, childRid, oldFnid).Scan(&delRow.fid, &delRow.pid, &delRow.fnid, &delPfnid); err != nil { t.Fatalf("old.txt delete mlink row: %v", err) } if delRow.fid != 0 { t.Errorf("old.txt fid = %d, want 0 (vacated by the rename)", delRow.fid) } if delRow.pid != int64(xRid) { t.Errorf("old.txt pid = %d, want %d (parent's old.txt blob rid)", delRow.pid, xRid) } if delPfnid != 0 { t.Errorf("old.txt pfnid = %d, want 0 (a deletion, not a rename)", delPfnid) } } // TestInsertMlinks_MergeParentsGetPidNegativeOne exercises the SAME // three-case pid rule on the direct check-in path (insertMlinks in // manifest.go), confirming resolveMlinkParent produces identical // pid/pmid semantics regardless of which write path calls it. func TestInsertMlinks_MergeParentsGetPidNegativeOne(t *testing.T) { r := setupTestRepo(t) d := r.DB() rootContent := []byte("root content") trunkRid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "root.txt", Content: rootContent}}, Comment: "trunk seed", User: "tester", Time: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("trunk Checkin: %v", err) } onBranchContent := []byte("on-branch content") featureRid, _, err := Checkin(r, CheckinOpts{ Files: []File{ {Name: "root.txt", Content: rootContent}, {Name: "on-branch.txt", Content: onBranchContent}, }, Parent: trunkRid, Comment: "feature adds on-branch.txt", User: "tester", Time: time.Date(2026, 5, 1, 13, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("feature Checkin: %v", err) } // Merge commit via the direct path: primary=trunk, merge parent=feature. // on-branch.txt is new relative to trunk but already exists on feature, // so it must resolve to pid=-1; brand-new.txt exists in neither // parent, so it must resolve to pid=0. mergeNewContent := []byte("brand new content") mergeRid, _, err := Checkin(r, CheckinOpts{ Files: []File{ {Name: "root.txt", Content: rootContent}, {Name: "on-branch.txt", Content: onBranchContent}, {Name: "brand-new.txt", Content: mergeNewContent}, }, Parent: trunkRid, MergeParents: []libfossil.FslID{featureRid}, Comment: "merge feature into trunk", User: "tester", Time: time.Date(2026, 5, 1, 14, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("merge Checkin: %v", err) } // rowFor takes the subtest's own *testing.T so a Fatalf in one // subtest's lookup cannot abort its siblings (see the twin helper in // TestInsertCheckinMlinks_ThreeCasePidRule for the mechanism). rowFor := func(t *testing.T, name string) mlinkRow { t.Helper() var row mlinkRow err := d.QueryRow( `SELECT m.fid, m.pmid, m.pid, m.fnid FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name=?`, mergeRid, name, ).Scan(&row.fid, &row.pmid, &row.pid, &row.fnid) if err != nil { t.Fatalf("mlink row for %q: %v", name, err) } return row } t.Run("merge_added_file_gets_pid_negative_one", func(t *testing.T) { row := rowFor(t, "on-branch.txt") if row.pid != -1 { t.Errorf("on-branch.txt pid = %d, want -1 (added by merge)", row.pid) } if row.pmid != int64(trunkRid) { t.Errorf("on-branch.txt pmid = %d, want %d (primary parent)", row.pmid, trunkRid) } }) t.Run("normal_added_file_gets_pid_zero", func(t *testing.T) { row := rowFor(t, "brand-new.txt") if row.pid != 0 { t.Errorf("brand-new.txt pid = %d, want 0", row.pid) } }) t.Run("carried_over_file_gets_parent_fid", func(t *testing.T) { row := rowFor(t, "root.txt") var trunkFid int64 if err := d.QueryRow( `SELECT m.fid FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name='root.txt'`, trunkRid, ).Scan(&trunkFid); err != nil { t.Fatalf("trunk root.txt fid lookup: %v", err) } if row.pid != trunkFid { t.Errorf("root.txt pid = %d, want %d (the primary parent's file rid, carried over unchanged)", row.pid, trunkFid) } if row.pmid != int64(trunkRid) { t.Errorf("root.txt pmid = %d, want %d", row.pmid, trunkRid) } }) }