Artifact 7e7ab061a1a567a196c28d03fd6cfdfab5230b7dde2f138c59567d10002e33f7:
- File
internal/manifest/mlink.go
— part of check-in
[24c35dad87]
at
2026-07-19 16:50:45
on branch trunk
— fix: populate mlink parent columns on the Crosslink check-in path (#63)
* fix: populate mlink parent columns on the Crosslink check-in path
insertCheckinMlinks (the xfer/Crosslink ingestion path) wrote mlink rows with only (mid, fid, fnid) set, leaving pmid/pid NULL. Fossil's compute_fileage filters mlink.fid != mlink.pid, and in SQLite <int> != NULL evaluates to NULL rather than true, so every row written by this path was silently dropped from ancestry-walk queries - `fossil ls -R <repo> -r <rev>` returned empty for any commit that arrived over the wire, even though `fossil open` worked fine (it reads the manifest blob directly and never consults mlink).
Root cause: the two check-in paths that write mlink both needed to implement the same three-case pid convention (src/manifest.c:1668-1679 in canonical Fossil - carried-over/added/added-by-merge) and had drifted. insertMlinks (the direct check-in path) only handled two of the three cases and ignored merge parents entirely; insertCheckinMlinks didn't populate the parent columns at all.
Factor the convention into one place (internal/manifest/mlink.go): resolveMlinkParent implements the pid/pmid rule, insertMlinkRow wraps it and also fills pfnid (rename tracking) and mperm (permission encoding), which neither path wrote before. Both insertMlinks and insertCheckinMlinks now call insertMlinkRow, so the two write paths produce indistinguishable rows and cannot drift apart again.
insertCheckinMlinks also stopped skipping F-cards with an empty UUID (the delta-manifest deletion marker) - it now writes a row with fid=0, matching canonical's "deleted files have mlink.fid=0" and letting deletions participate in ancestry queries the same way Fossil's own writer does.
Un-gates ls_branch_tip_repro_test.go (previously untracked, guarded behind LIBFOSSIL_LS_BRANCH_TIP_REPRO) as a permanent regression test, and adds explicit coverage in internal/manifest/mlink_test.go for all three pid cases (normal add, merge add, delete) on both write paths.
* test: pin the deleted-file pid to the parent's exact file rid
Strengthens the deleted_file_gets_fid_zero subtest added for #29: it was only checking pid != 0, which would pass even if resolveMlinkParent picked the wrong nonzero value. Compare against the primary parent's actual mlink.fid for the same filename instead.
* fix: review findings on #29 — substring perm match, test isolation
Blocking: permToMperm switched on the whole perm string, matching only exact "x"/"l". Canonical manifest_file_mperm (src/manifest.c:1482-1492) does a substring test (strstr), and F-card perm fields can carry more than one character — internal/deck/parse.go:194 assigns the field verbatim from remote xfer input, and Fossil itself emits multi-char perm strings (the " w" rename placeholder, see #51). An exact match on e.g. "wx" would silently drop the executable bit, the exact invariant PR #48 landed to protect. Switch to strings.Contains, x checked before l to match canonical's order (the same idiom already used at internal/checkout/vfile.go:52). Added TestPermToMperm_SubstringMatch, including a "wx" case pinned to mperm=1.
Test isolation: rowFor in both mlink_test.go table-driven tests closed over the parent *testing.T, so a Fatalf in one subtest's row lookup aborted every subtest after it via runtime.Goexit unwinding the shared parent goroutine. Pass each subtest's own t into rowFor instead. Proven on the reviewer's revert run: after the deletion subtest's Fatalf, the merge-add and normal-add subtests never even started.
Tightened the carried-over-file assertion in TestInsertMlinks_MergeParentsGetPidNegativeOne from "pid != 0" (would pass for -1 too) to pin the exact parent fid, matching how the crosslink-path twin test already asserts it.
Documented (comment only, no behavior change) a known divergence from canonical in resolveMlinkParent: a file present in a merge parent with different content (a conflict resolved during the merge) should stay pid=0 in canonical Fossil, but this function returns pid=-1 for any file present in a merge parent regardless of content match. Low blast radius — every mlink consumer in this package filters pid != fid, which holds either way — but worth flagging so it isn't rediscovered as a surprise. (user: [email protected], size: 5895) [annotate] [blame] [check-ins using]
package manifest import ( "database/sql" "errors" "fmt" "strings" "github.com/danmestas/libfossil/db" libfossil "github.com/danmestas/libfossil/internal/fsltype" ) // maxMlinkMergeParents bounds the merge-parent lookup loop in // resolveMlinkParent. TigerStyle: every loop needs an explicit bound. A // check-in with more merge parents than this is almost certainly corrupt // input, not a legitimate octopus merge. const maxMlinkMergeParents = 1024 // permToMperm converts a Fossil F-card permission string to the mlink.mperm // encoding used by canonical Fossil (src/manifest.c add_one_mlink): 0 = // regular file, 1 = executable, 2 = symlink. // // Canonical manifest_file_mperm (src/manifest.c:1482-1492) does a substring // test (strstr), not an exact match: perm fields can carry more than one // character (e.g. Fossil's " w" rename placeholder — see #51), and // internal/deck/parse.go:194 assigns the F-card perm field verbatim from // remote input over xfer. An exact match would silently drop the // executable bit for any multi-character perm string containing "x", which // is the exact invariant PR #48 landed to protect. x is tested before l to // match canonical's check order. func permToMperm(perm string) int64 { switch { case strings.Contains(perm, "x"): return 1 case strings.Contains(perm, "l"): return 2 default: return 0 } } // resolveMlinkParent implements Fossil's mlink parent-column convention — // the single place in this package that knows it. See // src/manifest.c:1668-1679 (the add_mlink header comment) in canonical // Fossil: // // - a file carried over from the primary parent: pid = the parent file's // blob rid, pmid = the primary parent's manifest rid // - a file added by this check-in (absent from every parent): pid = 0 // - a file added by a merge (absent from the primary parent, present in // a merge parent): pid = -1 // // pmid is always the primary parent's manifest rid, or 0 if this check-in // has no parent at all. Both insertMlinks (direct check-in) and // insertCheckinMlinks (xfer/Crosslink ingestion) route every mlink row // through this function so the two write paths cannot drift apart again. func resolveMlinkParent(tx *db.Tx, fnid int64, primaryParentMid libfossil.FslID, mergeParentMids []libfossil.FslID) (pmid, pid int64, err error) { if tx == nil { panic("manifest.resolveMlinkParent: tx must not be nil") } if fnid <= 0 { panic("manifest.resolveMlinkParent: fnid must be positive") } if len(mergeParentMids) > maxMlinkMergeParents { panic("manifest.resolveMlinkParent: mergeParentMids exceeds bound") } if primaryParentMid <= 0 { return 0, 0, nil // no parent at all: file is new to this check-in } pmid = int64(primaryParentMid) var parentFid int64 err = tx.QueryRow("SELECT fid FROM mlink WHERE mid=? AND fnid=?", primaryParentMid, fnid).Scan(&parentFid) switch { case err == nil: return pmid, parentFid, nil // carried over from the primary parent case !errors.Is(err, sql.ErrNoRows): return 0, 0, fmt.Errorf("lookup primary parent fid: %w", err) } // Not present in the primary parent's file set. If a merge parent // carried this filename, the file was added by the merge (pid=-1); // otherwise it is genuinely new to this check-in (pid=0). // // Known divergence from canonical: real Fossil's rule is not "present // in a merge parent" but count(*) < nLink over the per-fnid mlink rows // it writes for every parent transition (src/manifest.c:1905-1915). For // a file that exists in a merge parent with DIFFERENT content — i.e. a // conflict resolved during the merge — canonical emits an auxiliary // row and leaves pid=0, but this function returns pid=-1. Low blast // radius: every mlink consumer in this package (finfo.go, dephantomize.go) // filters on pid != fid, which holds either way. Left as-is rather than // tracking per-parent content equality, which the single-row-per-file // model this package uses does not otherwise need. for _, mp := range mergeParentMids { if mp <= 0 { continue } var exists int64 err = tx.QueryRow("SELECT 1 FROM mlink WHERE mid=? AND fnid=?", mp, fnid).Scan(&exists) switch { case err == nil: return pmid, -1, nil // added by merge case !errors.Is(err, sql.ErrNoRows): return 0, 0, fmt.Errorf("lookup merge parent fid: %w", err) } } return pmid, 0, nil // added by this check-in } // insertMlinkRow inserts one mlink row, resolving pmid/pid via // resolveMlinkParent and pfnid via oldName (empty if the file was not // renamed). fid must be 0 when the check-in deletes the file. mperm is the // raw Fossil permission string ("", "x", "l"); this function converts it. // isaux is always written 0: neither write path in this package produces // more than one mlink row per (mid, fnid), so the auxiliary/merge-parent // row distinction canonical Fossil uses does not arise here. func insertMlinkRow(tx *db.Tx, mid libfossil.FslID, fid int64, fnid int64, oldName string, perm string, primaryParentMid libfossil.FslID, mergeParentMids []libfossil.FslID) error { if tx == nil { panic("manifest.insertMlinkRow: tx must not be nil") } if mid <= 0 { panic("manifest.insertMlinkRow: mid must be positive") } if fnid <= 0 { panic("manifest.insertMlinkRow: fnid must be positive") } pmid, pid, err := resolveMlinkParent(tx, fnid, primaryParentMid, mergeParentMids) if err != nil { return fmt.Errorf("resolve mlink parent: %w", err) } var pfnid int64 if oldName != "" { pfnid, err = ensureFilename(tx, oldName) if err != nil { return fmt.Errorf("prior filename %q: %w", oldName, err) } } if _, err := tx.Exec( "INSERT INTO mlink(mid, fid, pmid, pid, fnid, pfnid, mperm, isaux) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", mid, fid, pmid, pid, fnid, pfnid, permToMperm(perm), 0, ); err != nil { return fmt.Errorf("mlink: %w", err) } return nil }