fix(git-sync): review #4404 batch — sanitize-title echo, per-space gate, move-echo, merge-agreement, fence-aware conflict scan, e2e asserts
Addresses reviewer comment #4404 (critical + blocking): - Critical #2: renamePage skips the echo where the incoming title equals sanitizeTitle(current title) — a Docmost title with FS-hostile chars (: / " |, newlines, double-space, >120) was pulled to a sanitized stem then written back, permanently corrupting the real title. (datasource) - Blocking #3: runOnce enforces per-space settings.gitSync.enabled (the event path bypassed opt-in; any edited space would git-init + export). (orchestrator) - Blocking #6: movePage no-ops the position-less same-parent echo that clobbered the user's chosen sibling order. (datasource) - Blocking #9: hasConflictMarkers is fence-aware — '<<<<<<< HEAD' inside a code block (git-tutorial page) no longer trips the all-or-nothing gate that froze the whole space's refs. (push.ts) - Blocking #11: three-way tryMergeRegion short-circuits when live==target (diff3 agreement) instead of logging a false 'same-block conflict resolved to git' — the echo noise that masked real data-loss signals. (three-way-merge) - Blocking #12/#13: e2e-advanced — drop the delete-cap block (no such feature; failed with a scary '(data loss!)'); non-member assert now expects 404 (existence not leaked), not 403. Verified on stand: sanitized-title rename preserves DB title (vault file sanitized); non-enabled space creates no vault; fenced conflict markers ingest without jamming; build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@ export interface GitSyncRunStatus {
|
||||
| 'in-progress'
|
||||
| 'disabled'
|
||||
| 'no-service-user'
|
||||
| 'space-not-enabled'
|
||||
| 'merge-in-progress';
|
||||
pull?: { written: number; deleted: number; conflict: boolean };
|
||||
push?: { mode: string; failures: number };
|
||||
@@ -118,6 +119,24 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative per-space opt-in check used by the EVENT path (runOnce), which
|
||||
* — unlike the poll path — is not pre-filtered by enabledSpaces(). Same STRICT
|
||||
* `settings.gitSync.enabled === 'true'` semantics; a missing/soft-deleted space
|
||||
* or any non-'true' value returns false (review #3).
|
||||
*/
|
||||
private async isSpaceGitSyncEnabled(spaceId: string): Promise<boolean> {
|
||||
const row = await this.db
|
||||
.selectFrom('spaces')
|
||||
.select(
|
||||
sql<boolean>`settings->'gitSync'->>'enabled' = 'true'`.as('enabled'),
|
||||
)
|
||||
.where('id', '=', spaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
return row?.enabled ?? false;
|
||||
}
|
||||
|
||||
// --- one sync cycle for a space -------------------------------
|
||||
|
||||
/**
|
||||
@@ -187,6 +206,16 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
return { spaceId, ran: false, skipped: 'no-service-user' };
|
||||
}
|
||||
// Per-space opt-in gate (review #3). The global GIT_SYNC_ENABLED env switch is
|
||||
// NOT sufficient — sync must also be turned on for THIS space
|
||||
// (`space.settings.gitSync.enabled === true`). The poll path filters via
|
||||
// enabledSpaces(), but the event path (page-change listener) calls runOnce
|
||||
// directly; without this check an edit in ANY space would `git init` a vault
|
||||
// and export that space's whole history, violating the module's opt-in
|
||||
// contract (orchestrator docstring §79-86). STRICT: anything but 'true' skips.
|
||||
if (!(await this.isSpaceGitSyncEnabled(spaceId))) {
|
||||
return { spaceId, ran: false, skipped: 'space-not-enabled' };
|
||||
}
|
||||
|
||||
// Run the full cycle under the per-space lock. withSpaceLock owns the
|
||||
// in-process mutex (no overlapping cycles on this instance) AND the Redis
|
||||
|
||||
@@ -362,6 +362,17 @@ export class GitmostDataSourceService {
|
||||
throw new NotFoundException(`Page ${pageId} not found`);
|
||||
}
|
||||
|
||||
// GS-MOVE-ECHO guard (review #6). A drag-move in Docmost echoes back through
|
||||
// git-sync as movePage(pageId, sameParent) WITHOUT a position. Recomputing a
|
||||
// position here would append the page to the end of its sibling list,
|
||||
// clobbering the position the user just chose. If the parent is unchanged and
|
||||
// no explicit position was provided, there is nothing to reparent — skip, so
|
||||
// the user's ordering is preserved. A real reparent (parent differs) or an
|
||||
// explicit position still proceeds.
|
||||
if (position == null && parentPageId === (page.parentPageId ?? null)) {
|
||||
return { id: pageId, skipped: 'no-op-move-echo' };
|
||||
}
|
||||
|
||||
const resolvedPosition =
|
||||
position ?? (await this.computeMovePosition(page.spaceId, parentPageId));
|
||||
|
||||
@@ -429,6 +440,26 @@ export class GitmostDataSourceService {
|
||||
page.slugId && title.endsWith(suffix)
|
||||
? title.slice(0, -suffix.length)
|
||||
: title;
|
||||
// GS-TITLE-SANITIZE guard (review Critical #2). A rename in Docmost to a
|
||||
// title with filename-hostile chars (`:` `/` `"` `|`, newlines, double
|
||||
// spaces, >120 chars) is pulled to a SANITIZED file stem; the same cycle then
|
||||
// sees an R(ename) line and would call renamePage with that sanitized stem,
|
||||
// PERMANENTLY replacing the real title with its sanitized form (e.g.
|
||||
// "Project: Plan" -> "Project- Plan"). In that echo the incoming title equals
|
||||
// `sanitizeTitle(current title)`, so skip the write — the sanitized stem is a
|
||||
// local filesystem artifact, never the page's real Docmost title. A genuine
|
||||
// retitle does NOT equal the sanitized current title, so it still applies.
|
||||
const { sanitizeTitle } = await loadGitSync();
|
||||
if (
|
||||
page.title &&
|
||||
cleanTitle !== page.title &&
|
||||
sanitizeTitle(page.title) === cleanTitle
|
||||
) {
|
||||
this.logger.log(
|
||||
`git-sync: skip rename of page ${pageId}: incoming title is the sanitized form of current title (filesystem artifact; real title preserved)`,
|
||||
);
|
||||
return { id: pageId };
|
||||
}
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user