From c7e034cab97977f7399d50019e3c3f988b3388cc Mon Sep 17 00:00:00 2001 From: claude-stand Date: Thu, 2 Jul 2026 13:47:48 +0300 Subject: [PATCH] fix(git-sync): don't trash a page on cross-space move (move-to-space data loss) A page moved to another space with git-sync enabled was sent to Trash and vanished from BOTH vaults. The source space's push phase sees the moved-away page's file gone from its vault and calls deletePage -> soft-delete, even though the page still lives in the destination space. Thread the reconciling spaceId into the bind context and, in deletePage, skip the soft-delete when the page's CURRENT space differs from the space being reconciled (a move-out): only the vault file is dropped, the page is preserved. Genuine in-space deletions are unaffected (space matches). Found by autonomous QA (web-test-orchestrator). Control: with git-sync OFF the move keeps deleted_at NULL; with it ON the page was trashed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/git-sync.orchestrator.ts | 6 ++++- .../services/gitmost-datasource.service.ts | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/server/src/integrations/git-sync/services/git-sync.orchestrator.ts b/apps/server/src/integrations/git-sync/services/git-sync.orchestrator.ts index f9f8cf13..2d31d90a 100644 --- a/apps/server/src/integrations/git-sync/services/git-sync.orchestrator.ts +++ b/apps/server/src/integrations/git-sync/services/git-sync.orchestrator.ts @@ -368,7 +368,11 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy { const { runCycle } = await loadGitSync(); const settings = await this.buildSettings(spaceId); const vault = await this.vaultRegistry.getVault(spaceId); - const client = this.dataSource.bind({ workspaceId, userId: serviceUserId }); + const client = this.dataSource.bind({ + workspaceId, + userId: serviceUserId, + spaceId, + }); const result = await runCycle({ // Cooperative-abort signal from the per-space lock: if a heartbeat refresh diff --git a/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts b/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts index afa77298..9ed045c2 100644 --- a/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts +++ b/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts @@ -23,6 +23,14 @@ import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.d export interface GitSyncBindContext { workspaceId: string; userId: string; + /** + * The space this cycle reconciles. Used to distinguish a genuine page deletion + * from a cross-space MOVE: when a page leaves space A, A's vault file is removed + * and the push phase would otherwise soft-delete the page — but the page still + * lives in space B. `deletePage` skips the soft-delete when the page's current + * space differs from the reconciling space. Optional for back-compat. + */ + spaceId?: string; } /** @@ -237,6 +245,24 @@ export class GitmostDataSourceService { ctx: GitSyncBindContext, pageId: string, ): Promise { + // Cross-space MOVE guard. A push-phase delete fires when a page's file + // disappears from THIS space's vault. That happens for a genuine deletion — + // but ALSO when the page was moved to another space (source vault file + // removed, page recreated in the destination vault). In the move case the + // page still exists and must NOT be trashed: soft-deleting it here loses the + // page from BOTH vaults and dumps it in Trash (observed data-loss on + // move-to-space with git-sync enabled). If the page's CURRENT space differs + // from the space we're reconciling, this is a move-out — drop only the vault + // file (already done by the engine), never the page. + if (ctx.spaceId) { + const page = await this.pageRepo.findById(pageId); + if (page && page.deletedAt == null && page.spaceId !== ctx.spaceId) { + this.logger.log( + `git-sync[${ctx.spaceId}] skip delete of page ${pageId}: moved to space ${page.spaceId} (vault file removed, page preserved)`, + ); + return { id: pageId, skipped: 'moved-to-other-space' }; + } + } await this.pageService.removePage( pageId, ctx.userId,