diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 8263328d9..e857ec96d 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1980,7 +1980,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise `${r.from} -> ${r.to}`).join(', ')}`); + if (totalChanges === 0) slog(` No syncable changes.`); + return { + status: 'dry_run', + fromCommit: lastCommit, + toCommit: headCommit, + added: filtered.added.length, + modified: filtered.modified.length, + deleted: filtered.deleted.length, + renamed: filtered.renamed.length, + chunksCreated: 0, + embedded: 0, + pagesAffected: [], + }; + } + // Delete pages that became un-syncable (modified but filtered out). // v0.20.0 Cathedral II SP-5: resolveSlugForPath picks the right slug shape // (markdown vs code) based on the chunker's classifier, so a Rust file that @@ -2438,31 +2463,6 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise `${r.from} -> ${r.to}`).join(', ')}`); - if (totalChanges === 0) slog(` No syncable changes.`); - return { - status: 'dry_run', - fromCommit: lastCommit, - toCommit: headCommit, - added: filtered.added.length, - modified: filtered.modified.length, - deleted: filtered.deleted.length, - renamed: filtered.renamed.length, - chunksCreated: 0, - embedded: 0, - pagesAffected: [], - }; - } - if (totalChanges === 0) { // #3068: same guard as the git-HEAD-equality gate above — a failed pull // plus zero imports must not produce a clean `up_to_date` (and must not diff --git a/test/sync.test.ts b/test/sync.test.ts index c7835b35b..df4d72273 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -422,6 +422,104 @@ describe('performSync dry-run never writes', () => { expect(bookmarkAfterDry).toBe(bookmarkAfterReal); }); + test('strategy-changing dry-run preserves previously indexed out-of-strategy pages', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + await performSync(engine, { + repoPath, + noPull: true, + noEmbed: true, + }); + const pageBefore = await engine.getPage('people/alice'); + const bookmarkBefore = await engine.getConfig('sync.last_commit'); + expect(pageBefore).not.toBeNull(); + expect(bookmarkBefore).not.toBeNull(); + + writeFileSync(join(repoPath, 'people/alice.md'), [ + '---', + 'type: person', + 'title: Alice', + '---', + '', + 'Alice changed after the initial sync.', + ].join('\n')); + execSync('git add -A && git commit -m "update alice"', { cwd: repoPath, stdio: 'pipe' }); + + const result = await performSync(engine, { + repoPath, + strategy: 'code', + dryRun: true, + noPull: true, + noEmbed: true, + }); + + expect(result.status).toBe('dry_run'); + const pageAfter = await engine.getPage('people/alice'); + expect(pageAfter).not.toBeNull(); + expect(pageAfter!.compiled_truth).toBe(pageBefore!.compiled_truth); + expect(await engine.getConfig('sync.last_commit')).toBe(bookmarkBefore); + }); + + test('strategy-changing real sync deletes previously indexed out-of-strategy pages', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + await performSync(engine, { + repoPath, + noPull: true, + noEmbed: true, + }); + expect(await engine.getPage('people/alice')).not.toBeNull(); + + writeFileSync(join(repoPath, 'people/alice.md'), [ + '---', + 'type: person', + 'title: Alice', + '---', + '', + 'Alice changed after the initial sync.', + ].join('\n')); + execSync('git add -A && git commit -m "update alice"', { cwd: repoPath, stdio: 'pipe' }); + + await performSync(engine, { + repoPath, + strategy: 'code', + noPull: true, + noEmbed: true, + }); + + expect(await engine.getPage('people/alice')).toBeNull(); + }); + + test('dry-run does not attempt git pull when origin exists', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const remotePath = mkdtempSync(join(tmpdir(), 'gbrain-sync-dryrun-remote-')); + + try { + execSync('git init --bare', { cwd: remotePath, stdio: 'pipe' }); + execSync(`git remote add origin ${JSON.stringify(remotePath)}`, { + cwd: repoPath, + stdio: 'pipe', + }); + const messages: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + messages.push(args.map(String).join(' ')); + }; + try { + const result = await performSync(engine, { + repoPath, + dryRun: true, + noEmbed: true, + }); + expect(result.status).toBe('dry_run'); + } finally { + console.error = originalError; + } + expect(messages.some(message => message.includes('sync.git_pull start'))).toBe(false); + expect(messages.some(message => message.includes('git pull failed'))).toBe(false); + } finally { + rmSync(remotePath, { recursive: true, force: true }); + } + }); + test('full-sync (--full) dry-run does NOT write to DB or advance the bookmark', async () => { const { performSync } = await import('../src/commands/sync.ts'); // Seed the bookmark so we hit the full-sync-with-bookmark path when --full is set.