diff --git a/apps/openant-cli/internal/python/runtime.go b/apps/openant-cli/internal/python/runtime.go index ddaa67e7..3a32ad22 100644 --- a/apps/openant-cli/internal/python/runtime.go +++ b/apps/openant-cli/internal/python/runtime.go @@ -201,8 +201,7 @@ func CheckOpenantInstalled(pythonPath string) error { } // Save dependency hash so CheckDepsStale knows this is the baseline. - pyprojectPath := filepath.Join(corePath, "pyproject.toml") - if h, err := hashFile(pyprojectPath); err == nil { + if h, err := depsHash(corePath); err == nil { if err := writeStoredHash(h); err != nil { fmt.Fprintf(os.Stderr, "warning: could not save dependency hash at %s: %v (next run may reinstall)\n", @@ -285,13 +284,26 @@ func readStoredHash() string { return readHashAt(depsHashPath()) } // writeStoredHash saves the dependency hash to the venv marker file. func writeStoredHash(hash string) error { return writeHashAt(depsHashPath(), hash) } +// depsHash keys the dependency stamp on BOTH pyproject.toml contents AND corePath (the +// editable-install source). The managed venv is a single global path shared across worktrees; +// without corePath in the key, two worktrees with identical pyproject.toml share one editable +// install and a binary built in one silently imports the other's source. Including corePath forces +// a reinstall (re-pointing the editable install) when the active source changes. +func depsHash(corePath string) (string, error) { + pyproject, err := os.ReadFile(filepath.Join(corePath, "pyproject.toml")) + if err != nil { + return "", err + } + sum := sha256.Sum256(append([]byte(corePath+"\x00"), pyproject...)) + return hex.EncodeToString(sum[:]), nil +} + // depsStalenessAt inspects pyproject.toml at corePath and the hash stored at // hashPath, and reports whether a reinstall is needed. The boolean is true // when deps are stale (i.e. the hash differs and a reinstall is warranted). // The caller is expected to skip the check on any error. func depsStalenessAt(corePath, hashPath string) (stale bool, currentHash string, err error) { - pyprojectPath := filepath.Join(corePath, "pyproject.toml") - currentHash, err = hashFile(pyprojectPath) + currentHash, err = depsHash(corePath) if err != nil { return false, "", err } diff --git a/apps/openant-cli/internal/python/runtime_corepath_test.go b/apps/openant-cli/internal/python/runtime_corepath_test.go new file mode 100644 index 00000000..90bdb269 --- /dev/null +++ b/apps/openant-cli/internal/python/runtime_corepath_test.go @@ -0,0 +1,53 @@ +package python + +import ( + "os" + "path/filepath" + "testing" +) + +// Regression test: the dependency-staleness check keyed only on +// pyproject.toml content, blind to corePath. The managed venv is a single global path shared across +// all worktrees; with two worktrees whose pyproject.toml is identical, the staleness check reports +// "not stale" even though the venv's editable install (`pip install -e `) points at the +// OTHER worktree's source — so a binary built in worktree B silently imports worktree A's Python. +// Fix: key staleness on corePath as well, so switching the editable-install source is detected. +func TestDepsStalenessDetectsCorePathChange(t *testing.T) { + const pyproject = "[project]\nname = \"openant\"\nversion = \"1.0.0\"\n" + + coreA := t.TempDir() + coreB := t.TempDir() + if err := os.WriteFile(filepath.Join(coreA, "pyproject.toml"), []byte(pyproject), 0644); err != nil { + t.Fatal(err) + } + // coreB has BYTE-IDENTICAL pyproject.toml — only the corePath differs. + if err := os.WriteFile(filepath.Join(coreB, "pyproject.toml"), []byte(pyproject), 0644); err != nil { + t.Fatal(err) + } + + hashPath := filepath.Join(t.TempDir(), ".deps-hash") + + // Baseline: install was done from coreA -> stamp coreA's hash. + _, hashA, err := depsStalenessAt(coreA, hashPath) + if err != nil { + t.Fatalf("depsStalenessAt(coreA): %v", err) + } + if err := writeHashAt(hashPath, hashA); err != nil { + t.Fatalf("writeHashAt: %v", err) + } + + // Same corePath -> not stale. + if staleA, _, _ := depsStalenessAt(coreA, hashPath); staleA { + t.Fatalf("same corePath reported stale (should be up-to-date)") + } + + // Different corePath, identical pyproject -> MUST be stale: otherwise the two worktrees share + // one editable install and the binary imports the wrong source. + staleB, _, err := depsStalenessAt(coreB, hashPath) + if err != nil { + t.Fatalf("depsStalenessAt(coreB): %v", err) + } + if !staleB { + t.Fatalf("corePath change (identical pyproject) not detected as stale -> worktrees share the editable install") + } +}