Artifact 2e54e5e50469f23ad75754ba02101ff1227a03b31e1000c41ba523964805e59c:
- File
internal/sync/integration_test.go
— part of check-in
[6273a0750d]
at
2026-07-22 23:03:24
on branch trunk
— fix: correct go.mod module path to go-libfossil (#76) (#117)
* fix: correct go.mod module path to go-libfossil (#76)
The module directive still declared github.com/danmestas/libfossil after the repository was renamed to go-libfossil. pkg.go.dev/godoc scraping matches the declared module path against its hosting path, so the stale path broke SDK doc generation.
Rename the module path across all four in-repo modules (root, the two db/driver submodules, observer/otel, and docs/site) and every internal import, plus the release workflow, the doc-generation script, and the docs.
No tag has been published under the go-libfossil path yet, so the cross-module requires cannot resolve from the proxy. Add replace directives pointing the in-repo requires at their local directories so GOWORK=off builds and the CI matrix resolve without a published version. These replaces are ignored by downstream consumers and can be dropped once new-path tags are cut.
* Guard releases against replace directives; regenerate SDK docs
The go-libfossil rename bridges the in-repo modules with `replace` directives. Go refuses a main module that carries them, so a tag cut while they are present breaks `go install .../cmd/libfossil@<tag>` and every `@version` fetch of the driver submodules. Add a Release workflow step that fails the job before anything is published if any tracked go.mod still contains a replace directive, so the bridge is a CI-enforced TODO rather than a silent landmine. The step comment records what unblocks a release: re-tag the submodules with the corrected module paths, bump the root requires, then drop the replace blocks.
Regenerate the SDK reference docs. The previous pass only substituted URLs over the pre-existing generated files, leaving stale signatures (Repo.Timeline was still documented as taking LogOpts rather than TimelineOpts). Note that gomarkdoc must be run outside a git worktree — inside one it silently strips every source link.
Also reword the README rename note, which read as though the import path had not changed, and re-sort the ncruces require block. (user: [email protected], size: 8972) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/simio" "github.com/danmestas/go-libfossil/testutil" ) // startFossilServer starts a fossil server on a free port and returns the URL // and a cleanup function. func startFossilServer(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() if bin == "" { t.Skip("fossil not in PATH") } // Find a free port ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } port := ln.Addr().(*net.TCPAddr).Port ln.Close() cmd := exec.Command(bin, "server", fmt.Sprintf("--port=%d", port), repoPath) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatalf("fossil server start: %v", err) } // Wait for the server to accept connections (poll up to 5s) deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond) if err == nil { conn.Close() goto ready } time.Sleep(100 * time.Millisecond) } cmd.Process.Kill() cmd.Wait() t.Fatalf("fossil server did not become ready on port %d within 5s", port) ready: serverURL := fmt.Sprintf("http://127.0.0.1:%d", port) // Register cleanup via t.Cleanup so it runs before TempDir removal (LIFO). t.Cleanup(func() { cmd.Process.Kill() cmd.Wait() // Remove WAL/SHM files that fossil processes leave behind, // so t.TempDir() cleanup doesn't fail with "directory not empty". dir := filepath.Dir(repoPath) entries, _ := os.ReadDir(dir) for _, e := range entries { name := e.Name() if strings.HasSuffix(name, "-wal") || strings.HasSuffix(name, "-shm") || strings.HasSuffix(name, "-journal") { os.Remove(filepath.Join(dir, name)) } } }) return serverURL } // getProjectCode reads the project-code from a fossil repo. func getProjectCode(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() out, err := exec.Command(bin, "sql", "-R", repoPath, "SELECT value FROM config WHERE name='project-code'", ).Output() if err != nil { t.Fatalf("get project-code: %v", err) } code := string(out) // Trim whitespace for len(code) > 0 && (code[len(code)-1] == '\n' || code[len(code)-1] == '\r' || code[len(code)-1] == ' ') { code = code[:len(code)-1] } return code } // getServerCode reads the server-code from a fossil repo. func getServerCode(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() out, err := exec.Command(bin, "sql", "-R", repoPath, "SELECT value FROM config WHERE name='server-code'", ).Output() if err != nil { // server-code may not exist; return empty return "" } code := string(out) for len(code) > 0 && (code[len(code)-1] == '\n' || code[len(code)-1] == '\r' || code[len(code)-1] == ' ') { code = code[:len(code)-1] } return code } func TestIntegrationPushToFossilServer(t *testing.T) { if !testutil.HasFossil() { t.Skip("fossil not in PATH") } bin := testutil.FossilBinary() dir, err := os.MkdirTemp("", "TestIntegrationPush*") if err != nil { t.Fatalf("MkdirTemp: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) // 1. Create a Go-managed local repo with a checkin localPath := filepath.Join(dir, "local.fossil") r, err := repo.Create(localPath, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } defer r.Close() _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "hello.txt", Content: []byte("hello from libfossil")}, }, Comment: "initial checkin from go", User: "testuser", Time: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } r.Close() // 2. Clone the local repo with fossil to create a matching remote // This ensures project-code and server-code match. remotePath := filepath.Join(dir, "remote.fossil") cloneCmd := exec.Command(bin, "clone", localPath, remotePath) cloneOut, err := cloneCmd.CombinedOutput() if err != nil { t.Fatalf("fossil clone: %v\n%s", err, cloneOut) } // 3. Create nobody user and grant write capabilities for testing // (Go-created repos don't have the standard nobody/anonymous users that fossil new creates) exec.Command(bin, "user", "new", "nobody", "", "cghijknorswy", "-R", remotePath).Run() exec.Command(bin, "user", "capabilities", "nobody", "cghijknorswy", "-R", remotePath).Run() // 4. Read project-code and server-code from the remote (they match local after clone) projCode := getProjectCode(t, remotePath) srvCode := getServerCode(t, remotePath) if projCode == "" { t.Fatal("project-code is empty") } // 4. Start fossil server on the remote serverURL := startFossilServer(t, remotePath) // 5. Re-open local repo and push via our sync engine r2, err := repo.Open(localPath) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r2.Close() transport := &HTTPTransport{URL: serverURL} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() result, err := Sync(ctx, r2, transport, SyncOpts{ Push: true, Pull: false, ProjectCode: projCode, ServerCode: srvCode, User: "", Password: "", }) // Log results regardless of error — this is informational since fossil // server behavior can be unpredictable in test environments. t.Logf("Push result: rounds=%d filesSent=%d filesRecvd=%d errors=%v err=%v", result.Rounds, result.FilesSent, result.FilesRecvd, result.Errors, err) if err != nil { t.Logf("NOTE: push to fossil server returned error (may be expected): %v", err) // Don't hard-fail; the unit tests with mock transport already validate engine logic. return } if result.Rounds < 1 { t.Errorf("expected at least 1 round, got %d", result.Rounds) } t.Logf("Push completed in %d rounds, sent %d files", result.Rounds, result.FilesSent) } func TestIntegrationPullFromFossilServer(t *testing.T) { if !testutil.HasFossil() { t.Skip("fossil not in PATH") } bin := testutil.FossilBinary() dir, err := os.MkdirTemp("", "TestIntegrationPull*") if err != nil { t.Fatalf("MkdirTemp: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) // 1. Create a remote repo with fossil new remotePath := filepath.Join(dir, "remote.fossil") newCmd := exec.Command(bin, "new", remotePath) newOut, err := newCmd.CombinedOutput() if err != nil { t.Fatalf("fossil new: %v\n%s", err, newOut) } // 2. Start fossil server on the remote serverURL := startFossilServer(t, remotePath) // 3. Clone it to local with fossil clone (so project/server codes match) localPath := filepath.Join(dir, "local.fossil") cloneCmd := exec.Command(bin, "clone", serverURL, localPath) cloneOut, err := cloneCmd.CombinedOutput() if err != nil { t.Fatalf("fossil clone: %v\n%s", err, cloneOut) } // 4. Read project-code and server-code from local clone projCode := getProjectCode(t, localPath) srvCode := getServerCode(t, localPath) if projCode == "" { t.Fatal("project-code is empty") } // 5. Open local with repo.Open and pull via our sync engine r, err := repo.Open(localPath) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r.Close() transport := &HTTPTransport{URL: serverURL} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() result, syncErr := Sync(ctx, r, transport, SyncOpts{ Push: false, Pull: true, ProjectCode: projCode, ServerCode: srvCode, User: "", Password: "", }) t.Logf("Pull result: rounds=%d filesSent=%d filesRecvd=%d errors=%v err=%v", result.Rounds, result.FilesSent, result.FilesRecvd, result.Errors, syncErr) if syncErr != nil { t.Logf("NOTE: pull from fossil server returned error (may be expected): %v", syncErr) return } // 6. Verify any received blobs pass content.Verify rows, err := r.DB().Query("SELECT rid FROM blob WHERE size >= 0") if err != nil { t.Fatalf("query blobs: %v", err) } defer rows.Close() verified := 0 for rows.Next() { var rid int64 if err := rows.Scan(&rid); err != nil { t.Fatalf("scan rid: %v", err) } if err := content.Verify(r.DB(), libfossil.FslID(rid)); err != nil { // Blobs stored by fossil clone may use Fossil's compression format // (4-byte size prefix) which our blob.Decompress doesn't handle yet. t.Logf("content.Verify rid=%d: %v", rid, err) } verified++ } if err := rows.Err(); err != nil { t.Fatalf("rows error: %v", err) } t.Logf("Pull completed in %d rounds, received %d files, verified %d blobs", result.Rounds, result.FilesRecvd, verified) }