From 94cab6f3f3a60759639726fdf269162df37e394c Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 29 May 2026 13:09:39 +0200 Subject: [PATCH] fix(server): normalize project names before migrate to prevent case-only duplicates (#438) Before this fix, POST /projects/migrate compared old_project and new_project with an exact string equality check, so "repo_name" vs "Repo_Name" bypassed the skip guard and triggered a real migration, reintroducing the duplicate project problem fixed in #136. - server.go: normalize both names via store.NormalizeProject before the equality check; case-only differences now return status="skipped" - _helpers.sh: lowercase detect_project output via tr '[:upper:]' '[:lower:]' - session-start.sh: lowercase OLD_PROJECT at assignment --- internal/server/server.go | 8 +++- internal/server/server_test.go | 44 +++++++++++++++++++++ plugin/claude-code/scripts/_helpers.sh | 6 +-- plugin/claude-code/scripts/session-start.sh | 2 +- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 5acd001b..2d4a9863 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -836,7 +836,13 @@ func (s *Server) handleMigrateProject(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, "old_project and new_project are required") return } - if body.OldProject == body.NewProject { + // Normalize both names using the same rules the store applies so that + // case-only differences (e.g. "repo_name" vs "Repo_Name") are treated as + // identical and do not trigger a migration that would create duplicates. + // See: https://github.com/Gentleman-Programming/engram/issues/438 + normalizedOld, _ := store.NormalizeProject(body.OldProject) + normalizedNew, _ := store.NormalizeProject(body.NewProject) + if normalizedOld == normalizedNew { jsonResponse(w, http.StatusOK, map[string]any{"status": "skipped", "reason": "names are identical"}) return } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index b68b6b04..31ee12f8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1904,3 +1904,47 @@ func TestJudgeAndCompareRoutesValidateInput(t *testing.T) { t.Fatalf("expected missing observation 404, got %d body=%q", compareRec.Code, compareRec.Body.String()) } } + +// TestMigrateProjectCaseOnlySkipped asserts that POST /projects/migrate +// returns status "skipped" when old_project and new_project differ only by +// case — fixing #438 where the exact-string comparison let case-only renames +// slip through and create duplicate projects. +// +// The test seeds a session under "repo_name" so that the store would actually +// migrate if the server did not guard against case-only differences first. +func TestMigrateProjectCaseOnlySkipped(t *testing.T) { + st := newServerTestStore(t) + h := New(st, 0).Handler() + + // Seed a session under the lowercase project name so the store has data + // to migrate; without the fix the handler would call store.MigrateProject + // and rename "repo_name" → "Repo_Name", creating a duplicate. + seedReq := httptest.NewRequest(http.MethodPost, "/sessions", strings.NewReader( + `{"id":"s-case-migrate","project":"repo_name","directory":"/tmp/repo"}`, + )) + seedReq.Header.Set("Content-Type", "application/json") + seedRec := httptest.NewRecorder() + h.ServeHTTP(seedRec, seedReq) + if seedRec.Code != http.StatusCreated { + t.Fatalf("seed session: expected 201, got %d body=%s", seedRec.Code, seedRec.Body.String()) + } + + body := bytes.NewBufferString(`{"old_project":"repo_name","new_project":"Repo_Name"}`) + req := httptest.NewRequest(http.MethodPost, "/projects/migrate", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["status"] != "skipped" { + t.Fatalf("expected status=skipped for case-only difference, got %v (full response: %#v)", resp["status"], resp) + } +} diff --git a/plugin/claude-code/scripts/_helpers.sh b/plugin/claude-code/scripts/_helpers.sh index 428a3616..4a903b2c 100755 --- a/plugin/claude-code/scripts/_helpers.sh +++ b/plugin/claude-code/scripts/_helpers.sh @@ -13,7 +13,7 @@ detect_project() { if [ -n "$url" ]; then # Handles both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git) local name - name=$(echo "$url" | sed 's/\.git$//' | sed 's|.*[/:]||') + name=$(echo "$url" | sed 's/\.git$//' | sed 's|.*[/:]||' | tr '[:upper:]' '[:lower:]') if [ -n "$name" ]; then echo "$name" return @@ -24,10 +24,10 @@ detect_project() { local root root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) if [ -n "$root" ]; then - basename "$root" + basename "$root" | tr '[:upper:]' '[:lower:]' return fi # Final fallback: cwd basename (current behavior) - basename "$dir" + basename "$dir" | tr '[:upper:]' '[:lower:]' } diff --git a/plugin/claude-code/scripts/session-start.sh b/plugin/claude-code/scripts/session-start.sh index 3bc5dd8a..d077638a 100755 --- a/plugin/claude-code/scripts/session-start.sh +++ b/plugin/claude-code/scripts/session-start.sh @@ -20,7 +20,7 @@ source "${SCRIPT_DIR}/_helpers.sh" INPUT=$(cat) SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') CWD=$(echo "$INPUT" | jq -r '.cwd // empty') -OLD_PROJECT=$(basename "$CWD") +OLD_PROJECT=$(basename "$CWD" | tr '[:upper:]' '[:lower:]') PROJECT=$(detect_project "$CWD") # Ensure engram server is running