Skip to content

Commit 7fb528a

Browse files
fix(server): normalize project names before migrate to prevent case-only duplicates (#438) (#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 #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 b1f96d4 commit 7fb528a

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
@@ -836,7 +836,13 @@ func (s *Server) handleMigrateProject(w http.ResponseWriter, r *http.Request) {
836836
jsonError(w, http.StatusBadRequest, "old_project and new_project are required")
837837
return
838838
}
839-
if body.OldProject == body.NewProject {
839+
// Normalize both names using the same rules the store applies so that
840+
// case-only differences (e.g. "repo_name" vs "Repo_Name") are treated as
841+
// identical and do not trigger a migration that would create duplicates.
842+
// See: https://github.com/Gentleman-Programming/engram/issues/438
843+
normalizedOld, _ := store.NormalizeProject(body.OldProject)
844+
normalizedNew, _ := store.NormalizeProject(body.NewProject)
845+
if normalizedOld == normalizedNew {
840846
jsonResponse(w, http.StatusOK, map[string]any{"status": "skipped", "reason": "names are identical"})
841847
return
842848
}

internal/server/server_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,3 +1904,47 @@ func TestJudgeAndCompareRoutesValidateInput(t *testing.T) {
19041904
t.Fatalf("expected missing observation 404, got %d body=%q", compareRec.Code, compareRec.Body.String())
19051905
}
19061906
}
1907+
1908+
// TestMigrateProjectCaseOnlySkipped asserts that POST /projects/migrate
1909+
// returns status "skipped" when old_project and new_project differ only by
1910+
// case — fixing #438 where the exact-string comparison let case-only renames
1911+
// slip through and create duplicate projects.
1912+
//
1913+
// The test seeds a session under "repo_name" so that the store would actually
1914+
// migrate if the server did not guard against case-only differences first.
1915+
func TestMigrateProjectCaseOnlySkipped(t *testing.T) {
1916+
st := newServerTestStore(t)
1917+
h := New(st, 0).Handler()
1918+
1919+
// Seed a session under the lowercase project name so the store has data
1920+
// to migrate; without the fix the handler would call store.MigrateProject
1921+
// and rename "repo_name" → "Repo_Name", creating a duplicate.
1922+
seedReq := httptest.NewRequest(http.MethodPost, "/sessions", strings.NewReader(
1923+
`{"id":"s-case-migrate","project":"repo_name","directory":"/tmp/repo"}`,
1924+
))
1925+
seedReq.Header.Set("Content-Type", "application/json")
1926+
seedRec := httptest.NewRecorder()
1927+
h.ServeHTTP(seedRec, seedReq)
1928+
if seedRec.Code != http.StatusCreated {
1929+
t.Fatalf("seed session: expected 201, got %d body=%s", seedRec.Code, seedRec.Body.String())
1930+
}
1931+
1932+
body := bytes.NewBufferString(`{"old_project":"repo_name","new_project":"Repo_Name"}`)
1933+
req := httptest.NewRequest(http.MethodPost, "/projects/migrate", body)
1934+
req.Header.Set("Content-Type", "application/json")
1935+
rec := httptest.NewRecorder()
1936+
1937+
h.ServeHTTP(rec, req)
1938+
1939+
if rec.Code != http.StatusOK {
1940+
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
1941+
}
1942+
1943+
var resp map[string]any
1944+
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
1945+
t.Fatalf("decode response: %v", err)
1946+
}
1947+
if resp["status"] != "skipped" {
1948+
t.Fatalf("expected status=skipped for case-only difference, got %v (full response: %#v)", resp["status"], resp)
1949+
}
1950+
}

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
@@ -20,7 +20,7 @@ source "${SCRIPT_DIR}/_helpers.sh"
2020
INPUT=$(cat)
2121
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
2222
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
23-
OLD_PROJECT=$(basename "$CWD")
23+
OLD_PROJECT=$(basename "$CWD" | tr '[:upper:]' '[:lower:]')
2424
PROJECT=$(detect_project "$CWD")
2525

2626
# Ensure engram server is running

0 commit comments

Comments
 (0)