fix(git-sync): strip cosmetic ~<slugId> disambiguation suffix from ingested title

Two sibling pages that share a title collide on one vault filename, so the
layout appends a cosmetic ` ~<slugId>` suffix (engine disambiguate()). That
suffix is a local filesystem artifact and must never become the page's real
Docmost title, but on ingest the filename-derived title carried it back into
the DB on some paths (observed: intermittent same-title collision left a page
permanently titled "Title ~<slugId>"). Strip it in renamePage() — the single
choke point every git-sync title write funnels through — but only when the
trailing token equals THIS page's own slugId, so a genuine user title that
legitimately ends in ` ~token` is never corrupted (slugId is a random nanoid).

Repro: create two pages with the same title; ~1 in 4 the second page's title
is permanently polluted. After fix: 0/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude-stand
2026-07-02 12:57:12 +03:00
parent 7abce93543
commit 123e981808
@@ -313,13 +313,29 @@ export class GitmostDataSourceService {
if (!page) {
throw new NotFoundException(`Page ${pageId} not found`);
}
// Defensive de-pollution of the cosmetic ` ~<slugId>` disambiguation suffix.
// When two sibling pages share a title, the vault layout appends ` ~<slugId>`
// to the colliding file's stem (engine `disambiguate(name, slugId)` = exactly
// `${name} ~${slugId}`) so two pages never map to one `.md`. That suffix is a
// LOCAL filesystem artifact and must NEVER become the page's real Docmost
// title. A filename-derived title can carry it back in on ingest (observed:
// intermittent same-title collision left a page permanently titled
// "Title ~<slugId>"). Strip it at this single choke point every git-sync
// title write funnels through — but ONLY when the trailing token equals THIS
// page's own slugId, so a genuine user title that legitimately ends in
// ` ~token` is never corrupted (slugId is a random nanoid; no real collision).
const suffix = ` ~${page.slugId}`;
const cleanTitle =
page.slugId && title.endsWith(suffix)
? title.slice(0, -suffix.length)
: title;
// PageService.update takes a User; the git-sync service user is the
// responsible author. Only the id is read off it for lastUpdatedById.
// `pageId` satisfies the UpdatePageDto type; PageService.update reads the
// page id off `page`, not the DTO. Only `title` is applied here.
await this.pageService.update(
page,
{ pageId, title },
{ pageId, title: cleanTitle },
{ id: ctx.userId } as any,
GIT_SYNC_PROVENANCE,
);