diff --git a/Makefile b/Makefile index 0eb5d52..85315c6 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ MAGE_RUN = go run github.com/magefile/mage@$(MAGE_VERSION) DOCKER_IMAGE ?= skeeper DOCKER_TAG ?= dev -.PHONY: deps fmt lint modernize test test-integration cover build verify tools \ +.PHONY: deps fmt lint modernize test test-integration cover build install verify tools \ bun-lint bun-fmt bun-fmt-check hooks-install release-snapshot docker-build help deps: @@ -31,6 +31,9 @@ cover: build: @$(MAGE_RUN) build +install: build + @$(MAGE_RUN) install + verify: @$(MAGE_RUN) verify diff --git a/internal/sidecar/reconcile.go b/internal/sidecar/reconcile.go index c00b363..445f59c 100644 --- a/internal/sidecar/reconcile.go +++ b/internal/sidecar/reconcile.go @@ -464,7 +464,7 @@ func (s *Service) sidecarSnapshot( } files := map[string]reconcile.SnapshotFile{} objects := make([]string, 0) - objectPaths := map[string]string{} + objectPaths := map[string][]string{} prefix := namespace + "/" for raw := range strings.SplitSeq(out.Stdout, "\x00") { if raw == "" { @@ -489,7 +489,7 @@ func (s *Service) sidecarSnapshot( object := fields[2] files[rel] = reconcile.SnapshotFile{Path: rel, Size: size, Blob: object} objects = append(objects, object) - objectPaths[object] = rel + objectPaths[object] = append(objectPaths[object], rel) } if len(objects) == 0 { return files, nil @@ -499,12 +499,13 @@ func (s *Service) sidecarSnapshot( return nil, err } for object, content := range contents { - rel := objectPaths[object] - file := files[rel] - file.Content = content - file.Size = int64(len(content)) - file.SHA256 = sha256String(content) - files[rel] = file + for _, rel := range objectPaths[object] { + file := files[rel] + file.Content = content + file.Size = int64(len(content)) + file.SHA256 = sha256String(content) + files[rel] = file + } } return files, nil } diff --git a/internal/sidecar/service_test.go b/internal/sidecar/service_test.go index 8de84a2..073f9ef 100644 --- a/internal/sidecar/service_test.go +++ b/internal/sidecar/service_test.go @@ -219,6 +219,52 @@ func TestServiceVerifyAndFSCKUseLockfile(t *testing.T) { } } +func TestServiceFSCKHandlesDuplicateSidecarBlobs(t *testing.T) { + t.Run("Should handle duplicate sidecar blobs", func(t *testing.T) { + setGitIdentity(t) + + ctx := context.Background() + root := newMainRepo(t) + remote := newBareRepo(t) + cfg := singleNamespaceConfig(remote, "project", []string{"**/SPEC.md"}) + bootstrapRepo(t, root, cfg) + writeFile(t, root, "src/auth/SPEC.md", "# Shared\n") + writeFile(t, root, "src/billing/SPEC.md", "# Shared\n") + + service := sidecar.New(&gitexec.ExecRunner{}) + if _, err := service.Sync(ctx, root, sidecar.SyncOptions{}); err != nil { + t.Fatalf("sync: %v", err) + } + + fsck, err := service.FSCK(ctx, root, sidecar.FSCKOptions{}) + if err != nil { + t.Fatalf("fsck: %v", err) + } + if !fsck.OK { + t.Fatalf("expected duplicate-content specs to fsck clean: %#v", fsck) + } + + journalPath := filepath.Join(root, ".git", "skeeper", "hydration.json") + data, err := os.ReadFile(journalPath) + if err != nil { + t.Fatalf("read hydration journal: %v", err) + } + var journal state.HydrationJournal + if err := json.Unmarshal(data, &journal); err != nil { + t.Fatalf("decode hydration journal: %v", err) + } + files := journal.Namespaces["project"].Files + auth := files["src/auth/SPEC.md"] + billing := files["src/billing/SPEC.md"] + if auth.SHA256 == "" || billing.SHA256 == "" { + t.Fatalf("expected duplicate blob entries to keep sha256: %#v", files) + } + if auth.SHA256 != billing.SHA256 || auth.SidecarBlob != billing.SidecarBlob { + t.Fatalf("expected duplicate files to share digest and blob: %#v", files) + } + }) +} + func TestServiceHydrateBlocksLocalOnlyByDefaultAndPrunesToRescue(t *testing.T) { setGitIdentity(t) diff --git a/magefile.go b/magefile.go index 7f1378b..5e3d2a8 100644 --- a/magefile.go +++ b/magefile.go @@ -114,6 +114,20 @@ func Build() error { return sh.RunV("go", "build", "-trimpath", "-ldflags", buildLDFlags(), "-o", out, "./cmd/skeeper") } +// Install installs the application binary into GOBIN/GOPATH/bin with version ldflags. +func Install() error { + installPath, err := goInstallPath() + if err != nil { + return err + } + fmt.Printf("Installing %s to %s\n", appBinary, installPath) + if err := sh.RunV("go", "install", "-trimpath", "-ldflags", buildLDFlags(), "./cmd/skeeper"); err != nil { + return fmt.Errorf("install %s: %w", appBinary, err) + } + fmt.Printf("Installed %s to %s\n", appBinary, installPath) + return nil +} + // Verify runs the blocking gate: fmt -> lint -> test -> build. func Verify() { mg.SerialDeps(Fmt, Lint, Test, Build) @@ -239,6 +253,37 @@ func gitOutput(args ...string) string { return strings.TrimSpace(string(out)) } +func goInstallPath() (string, error) { + gobin, err := goEnv("GOBIN") + if err != nil { + return "", err + } + if gobin != "" { + return filepath.Join(gobin, appBinary), nil + } + gopath, err := goEnv("GOPATH") + if err != nil { + return "", err + } + if gopath == "" { + return "", fmt.Errorf("go env GOPATH returned an empty path") + } + firstGoPath := filepath.SplitList(gopath)[0] + if firstGoPath == "" { + return "", fmt.Errorf("go env GOPATH returned an empty first path") + } + return filepath.Join(firstGoPath, "bin", appBinary), nil +} + +func goEnv(key string) (string, error) { + cmd := exec.Command("go", "env", key) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("go env %s: %w", key, err) + } + return strings.TrimSpace(string(out)), nil +} + func runWithEnv(env map[string]string, name string, args ...string) error { cmd := exec.CommandContext(context.Background(), name, args...) cmd.Stdout = os.Stdout