Skip to content

Commit bed2d75

Browse files
Alan-TheGentlemanrubenqc
authored andcommitted
fix(server): normalize project names before migrate to prevent case-only duplicates (Gentleman-Programming#438) (Gentleman-Programming#451)
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 Gentleman-Programming#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
1 parent 5e7fa46 commit bed2d75

4 files changed

Lines changed: 55 additions & 5 deletions

File tree

internal/server/server.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,13 @@ func (s *Server) handleMigrateProject(w http.ResponseWriter, r *http.Request) {
622622
jsonError(w, http.StatusBadRequest, "old_project and new_project are required")
623623
return
624624
}
625-
if body.OldProject == body.NewProject {
625+
// Normalize both names using the same rules the store applies so that
626+
// case-only differences (e.g. "repo_name" vs "Repo_Name") are treated as
627+
// identical and do not trigger a migration that would create duplicates.
628+
// See: https://github.com/Gentleman-Programming/engram/issues/438
629+
normalizedOld, _ := store.NormalizeProject(body.OldProject)
630+
normalizedNew, _ := store.NormalizeProject(body.NewProject)
631+
if normalizedOld == normalizedNew {
626632
jsonResponse(w, http.StatusOK, map[string]any{"status": "skipped", "reason": "names are identical"})
627633
return
628634
}

internal/server/server_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,47 @@ func TestHandleDeletePrompt_BadID(t *testing.T) {
538538
t.Fatalf("expected 400 for invalid prompt id, got %d", rec.Code)
539539
}
540540
}
541+
542+
// TestMigrateProjectCaseOnlySkipped asserts that POST /projects/migrate
543+
// returns status "skipped" when old_project and new_project differ only by
544+
// case — fixing #438 where the exact-string comparison let case-only renames
545+
// slip through and create duplicate projects.
546+
//
547+
// The test seeds a session under "repo_name" so that the store would actually
548+
// migrate if the server did not guard against case-only differences first.
549+
func TestMigrateProjectCaseOnlySkipped(t *testing.T) {
550+
st := newServerTestStore(t)
551+
h := New(st, 0).Handler()
552+
553+
// Seed a session under the lowercase project name so the store has data
554+
// to migrate; without the fix the handler would call store.MigrateProject
555+
// and rename "repo_name" → "Repo_Name", creating a duplicate.
556+
seedReq := httptest.NewRequest(http.MethodPost, "/sessions", strings.NewReader(
557+
`{"id":"s-case-migrate","project":"repo_name","directory":"/tmp/repo"}`,
558+
))
559+
seedReq.Header.Set("Content-Type", "application/json")
560+
seedRec := httptest.NewRecorder()
561+
h.ServeHTTP(seedRec, seedReq)
562+
if seedRec.Code != http.StatusCreated {
563+
t.Fatalf("seed session: expected 201, got %d body=%s", seedRec.Code, seedRec.Body.String())
564+
}
565+
566+
body := bytes.NewBufferString(`{"old_project":"repo_name","new_project":"Repo_Name"}`)
567+
req := httptest.NewRequest(http.MethodPost, "/projects/migrate", body)
568+
req.Header.Set("Content-Type", "application/json")
569+
rec := httptest.NewRecorder()
570+
571+
h.ServeHTTP(rec, req)
572+
573+
if rec.Code != http.StatusOK {
574+
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
575+
}
576+
577+
var resp map[string]any
578+
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
579+
t.Fatalf("decode response: %v", err)
580+
}
581+
if resp["status"] != "skipped" {
582+
t.Fatalf("expected status=skipped for case-only difference, got %v (full response: %#v)", resp["status"], resp)
583+
}
584+
}

plugin/claude-code/scripts/_helpers.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ detect_project() {
1313
if [ -n "$url" ]; then
1414
# Handles both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git)
1515
local name
16-
name=$(echo "$url" | sed 's/\.git$//' | sed 's|.*[/:]||')
16+
name=$(echo "$url" | sed 's/\.git$//' | sed 's|.*[/:]||' | tr '[:upper:]' '[:lower:]')
1717
if [ -n "$name" ]; then
1818
echo "$name"
1919
return
@@ -24,10 +24,10 @@ detect_project() {
2424
local root
2525
root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)
2626
if [ -n "$root" ]; then
27-
basename "$root"
27+
basename "$root" | tr '[:upper:]' '[:lower:]'
2828
return
2929
fi
3030

3131
# Final fallback: cwd basename (current behavior)
32-
basename "$dir"
32+
basename "$dir" | tr '[:upper:]' '[:lower:]'
3333
}

plugin/claude-code/scripts/session-start.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ source "${SCRIPT_DIR}/_helpers.sh"
1717
INPUT=$(cat)
1818
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
1919
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
20-
OLD_PROJECT=$(basename "$CWD")
20+
OLD_PROJECT=$(basename "$CWD" | tr '[:upper:]' '[:lower:]')
2121
PROJECT=$(detect_project "$CWD")
2222

2323
# Ensure engram server is running

0 commit comments

Comments
 (0)