From bde902da812b835ea96794a11c4485bb9b27f243 Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Thu, 9 Jul 2026 16:21:06 -0300 Subject: [PATCH 1/3] feat(lock): add wippy.lock.local overlay for local replacement overrides Why: module replacements only lived in the tracked wippy.lock, so local development against sibling checkouts leaked machine-local paths into commits and broke contributors whose checkout layout differed. What: lock.New now reads a sibling ".local" overlay that carries only replacements (strict decode; non-replacement keys are rejected, an empty overlay is tolerated). Overlay entries merge over the tracked lock and win by "from". GetReplacement, GetReplacements and GetModuleLoadPaths return the effective view; a new GetTrackedReplacements exposes the base-only set. Write and the mutators stay base-only, so overlay content is never persisted. Validate checks the effective replacement paths. --- boot/deps/lock/errors.go | 8 + boot/deps/lock/lock.go | 98 +++++++++- boot/deps/lock/overlay_test.go | 323 +++++++++++++++++++++++++++++++++ boot/deps/lock/types.go | 6 + boot/deps/lock/validate.go | 2 +- 5 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 boot/deps/lock/overlay_test.go diff --git a/boot/deps/lock/errors.go b/boot/deps/lock/errors.go index f7ae8baa6..1295bc734 100644 --- a/boot/deps/lock/errors.go +++ b/boot/deps/lock/errors.go @@ -46,6 +46,14 @@ func NewCheckReplacementPathError(path string, cause error) apierror.Error { return apierror.New(apierror.Internal, fmt.Sprintf("failed to check replacement path %q", path)).WithCause(cause) } +func NewReadOverlayFileError(cause error) apierror.Error { + return apierror.New(apierror.Internal, "failed to read overlay lock file").WithCause(cause) +} + +func NewUnmarshalOverlayError(cause error) apierror.Error { + return apierror.New(apierror.Invalid, "failed to parse overlay lock file (wippy.lock.local may only contain replacements)").WithCause(cause) +} + func NewInvalidFormatError(name string) apierror.Error { return apierror.New(apierror.Invalid, fmt.Sprintf("invalid format (expected org/module, got %q)", name)) } diff --git a/boot/deps/lock/lock.go b/boot/deps/lock/lock.go index b92a8c528..8861dce8b 100644 --- a/boot/deps/lock/lock.go +++ b/boot/deps/lock/lock.go @@ -3,6 +3,9 @@ package lock import ( + "bytes" + "errors" + "io" "os" "path/filepath" "sort" @@ -14,10 +17,16 @@ import ( // DefaultFilename is the standard name for wippy lock files. const DefaultFilename = "wippy.lock" +// OverlaySuffix is appended to the lock file path to locate the local, +// gitignored overlay (e.g. wippy.lock -> wippy.lock.local) that carries +// developer-local replacements without touching the tracked lock. +const OverlaySuffix = ".local" + // Lock represents a lock file with operations for reading, writing, and querying. type Lock struct { - path string - data File + path string + overlay []Replacement + data File } // New creates a new Lock instance from the given path. @@ -48,9 +57,70 @@ func New(path string) (*Lock, error) { return nil, NewStatLockFileError(err) } + if err := lock.readOverlay(); err != nil { + return nil, err + } + return lock, nil } +// readOverlay loads developer-local replacements from the ".local" overlay +// when present. The overlay may only contain a replacements section; any other +// key is rejected. Overlay replacements are kept separate from the tracked data +// so they are never written back into the lock file. +func (l *Lock) readOverlay() error { + overlayPath := l.path + OverlaySuffix + + data, err := os.ReadFile(overlayPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return NewReadOverlayFileError(err) + } + + var overlay OverlayFile + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&overlay); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return NewUnmarshalOverlayError(err) + } + + l.overlay = overlay.Replacements + return nil +} + +// effectiveReplacements returns the tracked replacements overlaid with the local +// overlay, where overlay entries win by "from". Order is preserved: tracked +// entries first (updated in place when overridden), then overlay-only additions. +func (l *Lock) effectiveReplacements() []Replacement { + if len(l.overlay) == 0 { + return l.data.Replacements + } + + index := make(map[string]int, len(l.data.Replacements)+len(l.overlay)) + merged := make([]Replacement, 0, len(l.data.Replacements)+len(l.overlay)) + + for _, r := range l.data.Replacements { + index[r.From] = len(merged) + merged = append(merged, r) + } + + for _, r := range l.overlay { + if i, ok := index[r.From]; ok { + merged[i] = r + continue + } + index[r.From] = len(merged) + merged = append(merged, r) + } + + return merged +} + // Read loads the lock file from disk. func (l *Lock) Read() error { data, err := os.ReadFile(l.path) @@ -158,10 +228,11 @@ func (l *Lock) GetModules() []Module { return l.data.Modules } -// GetReplacement retrieves a replacement by from field. +// GetReplacement retrieves a replacement by from field from the effective set +// (tracked replacements overlaid with the local overlay). // Returns the replacement and true if found, zero value and false otherwise. func (l *Lock) GetReplacement(from string) (Replacement, bool) { - for _, r := range l.data.Replacements { + for _, r := range l.effectiveReplacements() { if r.From == from { return r, true } @@ -192,8 +263,16 @@ func (l *Lock) RemoveReplacement(from string) { l.data.Replacements = filtered } -// GetReplacements returns all replacements. +// GetReplacements returns the effective replacements: the tracked replacements +// overlaid with the local overlay (overlay entries win by "from"). func (l *Lock) GetReplacements() []Replacement { + return l.effectiveReplacements() +} + +// GetTrackedReplacements returns only the replacements persisted in the lock +// file, excluding any local overlay. Used where the overlay must never +// participate, such as regenerating the tracked lock during update. +func (l *Lock) GetTrackedReplacements() []Replacement { return l.data.Replacements } @@ -314,7 +393,8 @@ type ModuleLoadPath struct { // Replacement paths carry Module from replacement "from" and empty Version. func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { lockDir := filepath.Dir(l.path) - paths := make([]ModuleLoadPath, 0, 1+len(l.data.Replacements)+len(l.data.Modules)) + replacements := l.effectiveReplacements() + paths := make([]ModuleLoadPath, 0, 1+len(replacements)+len(l.data.Modules)) if l.data.Directories.Src != "" { paths = append(paths, ModuleLoadPath{ @@ -322,7 +402,9 @@ func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { }) } - for _, repl := range l.data.Replacements { + replaced := make(map[string]struct{}, len(replacements)) + for _, repl := range replacements { + replaced[repl.From] = struct{}{} if repl.To != "" { root := ResolveLockPath(lockDir, repl.To) path := moduleEntryLoadPath(root) @@ -338,7 +420,7 @@ func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { fullVendorDir := ResolveLockPath(lockDir, vendorDir) for _, mod := range l.data.Modules { - if _, hasReplacement := l.GetReplacement(mod.Name); hasReplacement { + if _, hasReplacement := replaced[mod.Name]; hasReplacement { continue } diff --git a/boot/deps/lock/overlay_test.go b/boot/deps/lock/overlay_test.go new file mode 100644 index 000000000..c1bfeaa1d --- /dev/null +++ b/boot/deps/lock/overlay_test.go @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: MPL-2.0 + +package lock + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestNew_LoadsOverlayReplacements(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +modules: + - name: wippy/x + version: v1.0.0 +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + r, ok := l.GetReplacement("wippy/x") + if !ok { + t.Fatal("expected overlay replacement for wippy/x") + } + if r.To != "./x-local" { + t.Errorf("expected ./x-local, got %q", r.To) + } +} + +func TestNew_OverlayWinsOverTrackedReplacement(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/x + to: ./tracked +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./overlay +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + r, _ := l.GetReplacement("wippy/x") + if r.To != "./overlay" { + t.Errorf("expected overlay to win with ./overlay, got %q", r.To) + } + + if len(l.GetReplacements()) != 1 { + t.Errorf("expected 1 effective replacement, got %d", len(l.GetReplacements())) + } +} + +func TestGetTrackedReplacements_ExcludesOverlay(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/tracked + to: ./tracked +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/overlay + to: ./overlay +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + tracked := l.GetTrackedReplacements() + if len(tracked) != 1 || tracked[0].From != "wippy/tracked" { + t.Fatalf("expected only tracked replacement, got %+v", tracked) + } + + if len(l.GetReplacements()) != 2 { + t.Errorf("expected 2 effective replacements, got %d", len(l.GetReplacements())) + } +} + +func TestGetModuleLoadPaths_ReflectsOverlay(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +modules: + - name: wippy/x + version: v1.0.0 +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + var replPath string + var sawVendorX bool + for _, mp := range l.GetModuleLoadPaths() { + if mp.Module == "wippy/x" { + if mp.Version == "" { + replPath = mp.Path + } else { + sawVendorX = true + } + } + } + + if sawVendorX { + t.Error("overlay-replaced module must not be loaded from vendor") + } + if !strings.HasSuffix(replPath, filepath.Join(tmpDir, "x-local")) && !strings.Contains(replPath, "x-local") { + t.Errorf("expected load path under x-local, got %q", replPath) + } +} + +func TestWrite_DoesNotPersistOverlay(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +modules: + - name: wippy/x + version: v1.0.0 +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := l.Write(); err != nil { + t.Fatalf("Write: %v", err) + } + + data, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("read back: %v", err) + } + if strings.Contains(string(data), "x-local") || strings.Contains(string(data), "replacements") { + t.Errorf("tracked lock must not contain overlay content, got:\n%s", data) + } + + reloaded, err := New(lockPath) + if err != nil { + t.Fatalf("reload: %v", err) + } + if len(reloaded.GetTrackedReplacements()) != 0 { + t.Errorf("expected no tracked replacements after write, got %d", len(reloaded.GetTrackedReplacements())) + } +} + +func TestNew_StrictOverlayRejectsNonReplacementKeys(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +`) + writeFile(t, lockPath+OverlaySuffix, `modules: + - name: wippy/x + version: v1.0.0 +replacements: + - from: wippy/x + to: ./x-local +`) + + _, err := New(lockPath) + if err == nil { + t.Fatal("expected error when overlay declares non-replacement keys") + } +} + +func TestNew_NoOverlayEffectiveEqualsTracked(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/x + to: ./tracked +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + if len(l.GetReplacements()) != len(l.GetTrackedReplacements()) { + t.Error("without overlay, effective replacements must equal tracked") + } + r, _ := l.GetReplacement("wippy/x") + if r.To != "./tracked" { + t.Errorf("expected ./tracked, got %q", r.To) + } +} + +func TestNew_OverlayPathDerivedFromCustomLockName(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "custom.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./x-local +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, ok := l.GetReplacement("wippy/x"); !ok { + t.Error("expected overlay next to custom.lock (custom.lock.local) to load") + } +} + +func TestNew_EmptyOverlayFileIsTolerated(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {"zero bytes", ""}, + {"comment only", "# SPDX-License-Identifier: MPL-2.0\n"}, + {"whitespace only", "\n\n \n"}, + } { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +replacements: + - from: wippy/x + to: ./tracked +`) + writeFile(t, lockPath+OverlaySuffix, tc.content) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("empty overlay must be tolerated, got %v", err) + } + if len(l.GetReplacements()) != 1 { + t.Errorf("expected tracked replacement to remain, got %d", len(l.GetReplacements())) + } + }) + } +} + +func TestValidate_OverlayReplacementPathChecked(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "wippy.lock") + + writeFile(t, lockPath, `directories: + modules: .wippy + src: ./src +`) + writeFile(t, lockPath+OverlaySuffix, `replacements: + - from: wippy/x + to: ./missing-x +`) + + l, err := New(lockPath) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := Validate(l); err == nil { + t.Fatal("expected validation error for missing overlay replacement path") + } + + if err := os.Mkdir(filepath.Join(tmpDir, "missing-x"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := Validate(l); err != nil { + t.Errorf("expected validation to pass once overlay path exists, got %v", err) + } +} diff --git a/boot/deps/lock/types.go b/boot/deps/lock/types.go index e2e03587c..609073eb7 100644 --- a/boot/deps/lock/types.go +++ b/boot/deps/lock/types.go @@ -10,6 +10,12 @@ type File struct { Options Options `yaml:"options,omitempty"` } +// OverlayFile represents the structure of a wippy.lock.local overlay file. +// It may only carry replacements; any other key is rejected on load. +type OverlayFile struct { + Replacements []Replacement `yaml:"replacements"` +} + // Options specifies runtime behavior for module loading. type Options struct { UnpackModules bool `yaml:"unpack_modules,omitempty"` // Extract .wapp to directories (default: false) diff --git a/boot/deps/lock/validate.go b/boot/deps/lock/validate.go index 61840ee6f..34a0c94d5 100644 --- a/boot/deps/lock/validate.go +++ b/boot/deps/lock/validate.go @@ -21,7 +21,7 @@ func Validate(l *Lock) error { } } - if err := ValidateReplacements(l.path, l.data.Replacements); err != nil { + if err := ValidateReplacements(l.path, l.GetReplacements()); err != nil { return NewInvalidReplacementsError(err) } From 2230184a7fa79921cb87272470ea5a02608465a4 Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Thu, 9 Jul 2026 16:21:25 -0300 Subject: [PATCH 2/3] refactor(update): resolve replacements from the tracked lock only Why: update regenerates the canonical wippy.lock; consuming the effective (overlay-merged) replacements would copy developer-local overrides back into the tracked lock and scan their sources into the resolved graph. What: switch the five replacement consumers in the full and targeted update flows to GetTrackedReplacements so the overlay never influences or leaks into the regenerated lock. The targeted skip check keeps the effective view, since it only decides whether to hub-update a locally overridden module, never what to persist. --- cmd/wippy/cmd/update.go | 10 ++--- cmd/wippy/cmd/update_test.go | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/cmd/wippy/cmd/update.go b/cmd/wippy/cmd/update.go index da2fb441d..b8d486008 100644 --- a/cmd/wippy/cmd/update.go +++ b/cmd/wippy/cmd/update.go @@ -146,7 +146,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Build set of replaced modules to exclude from hub resolution replacedModules := make(map[string]bool) if oldLockObj != nil { - for _, repl := range oldLockObj.GetReplacements() { + for _, repl := range oldLockObj.GetTrackedReplacements() { replacedModules[repl.From] = true } } @@ -196,7 +196,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Preserve all replacements from old lock file if oldLockObj != nil { - preserveReplacements(newLockObj, oldLockObj.GetReplacements()) + preserveReplacements(newLockObj, oldLockObj.GetTrackedReplacements()) } // Save lock file @@ -316,7 +316,7 @@ func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir stri oldLockObj, _ := lock.New(lockFilePath) replacedModules := make(map[string]bool) - for _, repl := range lockObj.GetReplacements() { + for _, repl := range lockObj.GetTrackedReplacements() { replacedModules[repl.From] = true } @@ -416,7 +416,7 @@ func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir stri } // Preserve all replacements from current lock file - preserveReplacements(newLockObj, lockObj.GetReplacements()) + preserveReplacements(newLockObj, lockObj.GetTrackedReplacements()) // Detect changes changes := lock.Diff(oldLockObj, newLockObj) @@ -498,7 +498,7 @@ func loadDependencyScanEntries(ctx context.Context, ldr boot.Loader, srcDir stri if lockObj != nil { replacements := make(map[string]bool) - for _, repl := range lockObj.GetReplacements() { + for _, repl := range lockObj.GetTrackedReplacements() { replacements[repl.From] = true } for _, mp := range lockObj.GetModuleLoadPaths() { diff --git a/cmd/wippy/cmd/update_test.go b/cmd/wippy/cmd/update_test.go index f940f9fcc..fd2f4f8b2 100644 --- a/cmd/wippy/cmd/update_test.go +++ b/cmd/wippy/cmd/update_test.go @@ -205,6 +205,87 @@ entries: } } +func TestLoadDependencyScanEntriesExcludesOverlayReplacementSources(t *testing.T) { + ctx := setupLoaderContext(t) + ldr := bootapi.GetLoader(ctx) + if ldr == nil { + t.Fatal("loader not available in test context") + } + + tmpDir := t.TempDir() + appDir := filepath.Join(tmpDir, "app") + overlayReplSrc := filepath.Join(tmpDir, "local", "overlay-ui", "src") + for _, dir := range []string{appDir, overlayReplSrc} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + + appYAML := `version: "1.0" +namespace: app.deps +entries: + - name: facade + kind: ns.dependency + component: wippy/facade + version: ">=v0.5.39" +` + if err := os.WriteFile(filepath.Join(appDir, "_index.yaml"), []byte(appYAML), 0o644); err != nil { + t.Fatalf("write app index: %v", err) + } + + overlayYAML := `version: "1.0" +namespace: overlay.ui.deps +entries: + - name: overlaydep + kind: ns.dependency + component: wippy/overlaydep + version: ">=v0.1.0" +` + if err := os.WriteFile(filepath.Join(overlayReplSrc, "_index.yaml"), []byte(overlayYAML), 0o644); err != nil { + t.Fatalf("write overlay index: %v", err) + } + + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + if err := os.WriteFile(lockPath, []byte(`directories: + modules: .wippy + src: app +modules: + - name: acme/ui + version: v1.0.0 +`), 0o644); err != nil { + t.Fatalf("write lock: %v", err) + } + if err := os.WriteFile(lockPath+lock.OverlaySuffix, []byte(`replacements: + - from: acme/ui + to: local/overlay-ui +`), 0o644); err != nil { + t.Fatalf("write overlay lock: %v", err) + } + + lockObj, err := lock.New(lockPath) + if err != nil { + t.Fatalf("create lock: %v", err) + } + + loaded, err := loadDependencyScanEntries(ctx, ldr, appDir, lockObj, zap.NewNop()) + if err != nil { + t.Fatalf("loadDependencyScanEntries failed: %v", err) + } + + deps := extractRootDependencies(loaded, payload.GetTranscoder(ctx)) + got := map[string]string{} + for _, dep := range deps { + got[dep.Org+"/"+dep.Module] = dep.Constraint + } + + if got["wippy/facade"] != ">=v0.5.39" { + t.Fatalf("app dependency missing: got %v", got) + } + if _, ok := got["wippy/overlaydep"]; ok { + t.Fatalf("update must not scan overlay replacement sources: got %v", got) + } +} + func mustWriteFile(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { From 6852712d7f2812f078679f49065c0c11e4e13cb1 Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Thu, 9 Jul 2026 16:21:39 -0300 Subject: [PATCH 3/3] feat(init): gitignore the wippy.lock.local overlay Why: the overlay is meant to stay local; without a default ignore rule developers would commit machine-local replacement paths. What: wippy init idempotently ensures the .gitignore next to the lock file lists wippy.lock.local, creating the file when absent and appending the entry on its own line when missing. --- cmd/wippy/cmd/gitignore.go | 49 ++++++++++++++++ cmd/wippy/cmd/gitignore_test.go | 101 ++++++++++++++++++++++++++++++++ cmd/wippy/cmd/init.go | 4 ++ 3 files changed, 154 insertions(+) create mode 100644 cmd/wippy/cmd/gitignore.go create mode 100644 cmd/wippy/cmd/gitignore_test.go diff --git a/cmd/wippy/cmd/gitignore.go b/cmd/wippy/cmd/gitignore.go new file mode 100644 index 000000000..715e76a5d --- /dev/null +++ b/cmd/wippy/cmd/gitignore.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + + "github.com/wippyai/runtime/boot/deps/lock" +) + +// ensureOverlayGitignored idempotently ensures the .gitignore next to the lock +// file lists the local overlay (e.g. wippy.lock.local) so machine-local +// replacement overrides never get committed. It creates the .gitignore when +// absent and appends the entry on its own line when missing, preserving any +// existing content. +func ensureOverlayGitignored(lockPath string) error { + entry := filepath.Base(lockPath) + lock.OverlaySuffix + gitignorePath := filepath.Join(filepath.Dir(lockPath), ".gitignore") + + existing, err := os.ReadFile(gitignorePath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + return os.WriteFile(gitignorePath, []byte(entry+"\n"), 0644) + } + + scanner := bufio.NewScanner(bytes.NewReader(existing)) + for scanner.Scan() { + if scanner.Text() == entry { + return nil + } + } + if err := scanner.Err(); err != nil { + return err + } + + var buf bytes.Buffer + buf.Write(existing) + if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) { + buf.WriteByte('\n') + } + buf.WriteString(entry + "\n") + + return os.WriteFile(gitignorePath, buf.Bytes(), 0644) +} diff --git a/cmd/wippy/cmd/gitignore_test.go b/cmd/wippy/cmd/gitignore_test.go new file mode 100644 index 000000000..264357a6f --- /dev/null +++ b/cmd/wippy/cmd/gitignore_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wippyai/runtime/boot/deps/lock" +) + +func countOccurrences(s, sub string) int { + return strings.Count(s, sub) +} + +func TestEnsureOverlayGitignored_CreatesFile(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, ".gitignore")) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if !strings.Contains(string(data), "wippy.lock.local") { + t.Errorf("expected wippy.lock.local entry, got:\n%s", data) + } +} + +func TestEnsureOverlayGitignored_AppendsPreservingContent(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + gitignorePath := filepath.Join(tmpDir, ".gitignore") + + if err := os.WriteFile(gitignorePath, []byte("node_modules/\n.wippy/\n"), 0o644); err != nil { + t.Fatalf("seed .gitignore: %v", err) + } + + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored: %v", err) + } + + data, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + content := string(data) + if !strings.Contains(content, "node_modules/") || !strings.Contains(content, ".wippy/") { + t.Errorf("existing content lost:\n%s", content) + } + if !strings.Contains(content, "wippy.lock.local") { + t.Errorf("entry not appended:\n%s", content) + } +} + +func TestEnsureOverlayGitignored_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + gitignorePath := filepath.Join(tmpDir, ".gitignore") + + for i := 0; i < 3; i++ { + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored run %d: %v", i, err) + } + } + + data, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if n := countOccurrences(string(data), "wippy.lock.local"); n != 1 { + t.Errorf("expected exactly one entry, got %d:\n%s", n, data) + } +} + +func TestEnsureOverlayGitignored_NoTrailingNewline(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + gitignorePath := filepath.Join(tmpDir, ".gitignore") + + if err := os.WriteFile(gitignorePath, []byte(".wippy/"), 0o644); err != nil { + t.Fatalf("seed .gitignore: %v", err) + } + + if err := ensureOverlayGitignored(lockPath); err != nil { + t.Fatalf("ensureOverlayGitignored: %v", err) + } + + data, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if !strings.Contains(string(data), ".wippy/\nwippy.lock.local") { + t.Errorf("entry must land on its own line:\n%s", data) + } +} diff --git a/cmd/wippy/cmd/init.go b/cmd/wippy/cmd/init.go index 7b2282f51..f75ff25a2 100644 --- a/cmd/wippy/cmd/init.go +++ b/cmd/wippy/cmd/init.go @@ -70,6 +70,10 @@ func runInit(cmd *cobra.Command, _ []string) error { return NewWriteLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) } + if err := ensureOverlayGitignored(lockObj.Path()); err != nil { + logger.Warn("failed to update .gitignore for local overlay", zap.Error(err)) + } + logger.Info("lock file initialized successfully") return nil }