fix(apply): preserve project name on a name-less re-apply

`upsert_project_tx` defaulted a missing `[project].name` to the slug and then
`ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name` wrote that fabricated
value unconditionally — so a re-apply from a clone whose manifest omits `name`
silently clobbered the stored display name back to the slug (visible in
`pic projects ls` / `pic groups ls`).

Bind the raw optional name once and guard both sides on it:
`VALUES ($1, COALESCE($2, $1), $3)` (first apply falls back to the slug for the
NOT NULL column) and `DO UPDATE SET name = COALESCE($2, projects.name)` (a
re-apply updates the name only when the manifest actually declares one). An
omitted optional field now preserves persisted data instead of mutating it.

Pinned by a new `reapply_without_name_preserves_project_name` journey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 07:37:25 +02:00
parent f673922d89
commit 74f7a67be7
2 changed files with 77 additions and 6 deletions

View File

@@ -3430,22 +3430,26 @@ fn validate_project_slug(slug: &str) -> Result<(), ApplyError> {
}
/// Register (or fetch) a project by slug inside the apply tx, returning its id.
/// First apply with a new slug inserts; a re-apply updates the display name
/// (the manifest is authoritative) but preserves the original `created_by`.
/// First apply with a new slug inserts, falling back to the slug when the
/// manifest omits `name` (the column is NOT NULL). A re-apply updates the
/// display name ONLY when the manifest actually declares one — a name-less
/// `[project]` block (e.g. a clone scaffolded without a name) preserves the
/// previously-set name and `created_by` rather than clobbering the name back
/// to the slug. `$2` (the optional declared name) is bound once and referenced
/// in both the INSERT fallback and the ON CONFLICT guard.
async fn upsert_project_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
decl: &ProjectDecl,
actor: AdminUserId,
) -> Result<ProjectId, ApplyError> {
validate_project_slug(&decl.slug)?;
let name = decl.name.clone().unwrap_or_else(|| decl.slug.clone());
let (id,): (uuid::Uuid,) = sqlx::query_as(
"INSERT INTO projects (slug, name, created_by) VALUES ($1, $2, $3) \
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name \
"INSERT INTO projects (slug, name, created_by) VALUES ($1, COALESCE($2, $1), $3) \
ON CONFLICT (slug) DO UPDATE SET name = COALESCE($2, projects.name) \
RETURNING id",
)
.bind(&decl.slug)
.bind(&name)
.bind(decl.name.clone())
.bind(actor.into_inner())
.fetch_one(&mut **tx)
.await