Artifact 7f512544bde6ba52a5007d1647532341c2141ee5e6f7c28effa77678015bf462:
- File
internal/sync/handler_crosslink_test.go
— part of check-in
[3f290b159f]
at
2026-04-26 02:57:51
on branch trunk
— v0.4.1: xfer encoder, multi-round sync, manifest crosslink (#4)
* xfer: handle empty ServerCode/ProjectCode without arg-count error
Repo.Sync(ctx, transport, SyncOpts{Push: true, Pull: true}) with both codes empty previously wrote "push \n" to the wire. The decoder's strings.Fields collapsed adjacent spaces and failed with "xfer: pull requires 1-2 args, got 0".
Encoder now skips trailing empty args. Decoder accepts the verb with 0 args as "no specific code requested" — the same semantic as the existing 1- and 2-arg forms after fossil's normal arg interpretation.
Also fixes session.cardSummary's slice-out-of-range panic on empty codes by clamping the prefix length.
Adds a roundtrip test for the empty-codes path.
* sync: continue round-loop while either side has pending work
Sync's round-loop previously exited when the user passed Pull=false even if the peer was still requesting gimmes. The user's Pull flag controls whether we *request* new content; it should not gate whether we *service* the peer's gimmes. Symptom: agent-infra's Manager.Push had to pass Pull=true as a workaround to drive multi-round transfers.
Root cause: HandleSync.handleIGot gated gimme generation on pullOK alone. Push-only clients send igot cards from sendUnclustered every round (they always announce what they have), but the server would silently drop them because pullOK was false — leaving the server holding only what the client pushed proactively.
Continue the loop while any of: pending ungots to push, pending pulls to request, gimmes to service. Exit only when a round completes with no work in either direction. Concretely the gate is now (pushOK || pullOK), restoring parity with fossil-scm's client_sync continuation logic.
Adds an integration test (TestPushOnly_DrainsServerGimmes) that exercises the push-only path end-to-end and a unit test that asserts gimme emission under push-only handleIGot.
* sync: crosslink incoming manifests so hub-side state stays consistent
Repo.HandleSync previously stored manifest blobs in 'blob' but did not populate the relational tables (event, leaf, plink, mlink) that the timeline, fork detection, and clone protocol depend on. As a result:
- Leaves pulling from a hub got blobs but stale 'leaf' state, so WouldFork reported phantom siblings. - Verifier-clone of a hub repo failed with "sync.Clone: exceeded 100 rounds" because 'mlink' was empty and the clone protocol couldn't traverse manifest parents. - Hub timelines were always empty even after successful Push.
HandleSync now invokes manifest.Crosslink (the same scanner Clone and Sync run after convergence) when at least one file landed in the round. The scanner walks blobs that have no event/tagxref/ forumpost/attachment row yet, parses them, and populates the relational tables. Local commits already produced this state via manifest.Checkin's existing crosslink path; incoming syncs now match.
Coverage spans every artifact type the existing scanner already supports (ci, wiki, ticket, event, attachment, cluster, forum, control). The agent-infra trial that surfaced this only exercises 'ci' artifacts, so the divergence note in the prompt described the narrowest possible repair; reusing the existing scanner avoids the divergence and matches the C reference's full coverage.
Adds two tests: TestHandleSync_CrosslinksReceivedManifest verifies event/leaf/mlink rows after pushing a single checkin; the leaf- transition test verifies the parent leaves the leaf table when the child lands and that plink connects them. (user: [email protected], size: 6158) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "testing" "time" "github.com/danmestas/libfossil/internal/blob" "github.com/danmestas/libfossil/internal/deck" "github.com/danmestas/libfossil/internal/hash" "github.com/danmestas/libfossil/internal/xfer" ) // buildCheckinManifest constructs a checkin manifest referencing the given // file blob and returns the wire bytes plus the manifest UUID. Parents may // be empty for a root-of-tree checkin. func buildCheckinManifest(t *testing.T, comment string, when time.Time, fileUUID string, fileContent []byte, parents []string) ([]byte, string) { t.Helper() d := &deck.Deck{ Type: deck.Checkin, C: comment, D: when, F: []deck.FileCard{{Name: "hello.txt", UUID: fileUUID}}, U: "tester", P: parents, T: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagSingleton, Name: "sym-trunk", UUID: "*"}, }, } rHash, err := d.ComputeR(func(uuid string) ([]byte, error) { if uuid == fileUUID { return fileContent, nil } return nil, fmt.Errorf("unknown uuid: %s", uuid) }) if err != nil { t.Fatalf("ComputeR: %v", err) } d.R = rHash manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } return manifestBytes, hash.SHA1(manifestBytes) } // TestHandleSync_CrosslinksReceivedManifest verifies that a manifest pushed // to the server via HandleSync is crosslinked into event/leaf/plink/mlink. // // Pre-fix, HandleSync stored the blob durably but did not call the // crosslink scanner, so the hub's timeline, fork detection, and clone // protocol all observed empty relational state. func TestHandleSync_CrosslinksReceivedManifest(t *testing.T) { r := setupSyncTestRepo(t) fileContent := []byte("hello from crosslink test") fileUUID := hash.SHA1(fileContent) manifestBytes, manifestUUID := buildCheckinManifest(t, "first checkin", time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC), fileUUID, fileContent, nil) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: fileUUID, Content: fileContent}, &xfer.FileCard{UUID: manifestUUID, Content: manifestBytes}, }} if _, err := HandleSync(context.Background(), r, req); err != nil { t.Fatalf("HandleSync: %v", err) } manifestRID, ok := blob.Exists(r.DB(), manifestUUID) if !ok { t.Fatal("manifest blob not stored") } // event table: one row of type='ci' for the checkin. var eventCount int if err := r.DB().QueryRow( "SELECT count(*) FROM event WHERE type='ci' AND objid=?", manifestRID, ).Scan(&eventCount); err != nil { t.Fatalf("count event: %v", err) } if eventCount != 1 { t.Fatalf("event ci rows for objid=%d: got %d, want 1", manifestRID, eventCount) } // leaf table: the checkin is a tip. var leafCount int if err := r.DB().QueryRow( "SELECT count(*) FROM leaf WHERE rid=?", manifestRID, ).Scan(&leafCount); err != nil { t.Fatalf("count leaf: %v", err) } if leafCount != 1 { t.Fatalf("leaf rows for rid=%d: got %d, want 1", manifestRID, leafCount) } // mlink table: one row mapping the manifest to its file. var mlinkCount int if err := r.DB().QueryRow( "SELECT count(*) FROM mlink WHERE mid=?", manifestRID, ).Scan(&mlinkCount); err != nil { t.Fatalf("count mlink: %v", err) } if mlinkCount != 1 { t.Fatalf("mlink rows for mid=%d: got %d, want 1", manifestRID, mlinkCount) } } // TestHandleSync_LeafTransitionOnChildCheckin verifies that when a child // checkin lands via HandleSync, the parent is removed from leaf and the // child becomes the new tip. This is the operation that produces phantom // "WouldFork" reports in v0.4.0 when crosslink is missing. func TestHandleSync_LeafTransitionOnChildCheckin(t *testing.T) { r := setupSyncTestRepo(t) parentFileContent := []byte("parent file content") parentFileUUID := hash.SHA1(parentFileContent) parentBytes, parentUUID := buildCheckinManifest(t, "parent", time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC), parentFileUUID, parentFileContent, nil) childFileContent := []byte("child file content") childFileUUID := hash.SHA1(childFileContent) childBytes, childUUID := buildCheckinManifest(t, "child", time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC), childFileUUID, childFileContent, []string{parentUUID}) // Push the parent first. if _, err := HandleSync(context.Background(), r, &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: parentFileUUID, Content: parentFileContent}, &xfer.FileCard{UUID: parentUUID, Content: parentBytes}, }}); err != nil { t.Fatalf("HandleSync(parent): %v", err) } parentRID, _ := blob.Exists(r.DB(), parentUUID) // Confirm parent is a leaf. var leafBefore int r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", parentRID).Scan(&leafBefore) if leafBefore != 1 { t.Fatalf("after parent push: leaf rows for parent=%d got %d, want 1", parentRID, leafBefore) } // Push the child. if _, err := HandleSync(context.Background(), r, &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &xfer.FileCard{UUID: childFileUUID, Content: childFileContent}, &xfer.FileCard{UUID: childUUID, Content: childBytes}, }}); err != nil { t.Fatalf("HandleSync(child): %v", err) } childRID, _ := blob.Exists(r.DB(), childUUID) // Parent must be gone from leaf, child must replace it. var parentLeaf, childLeaf int r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", parentRID).Scan(&parentLeaf) r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", childRID).Scan(&childLeaf) if parentLeaf != 0 { t.Errorf("parent leaf count = %d, want 0 (child should have superseded)", parentLeaf) } if childLeaf != 1 { t.Errorf("child leaf count = %d, want 1", childLeaf) } // plink table: one row connecting parent to child. var plinkCount int r.DB().QueryRow( "SELECT count(*) FROM plink WHERE pid=? AND cid=?", parentRID, childRID, ).Scan(&plinkCount) if plinkCount != 1 { t.Errorf("plink rows pid=%d cid=%d: got %d, want 1", parentRID, childRID, plinkCount) } }