diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 8d76e82fc..8952f9cdc 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -196,6 +196,33 @@ function git(repoPath: string, ...args: string[]): string { }).trim(); } +function isDetachedHead(repoPath: string): boolean { + try { + git(repoPath, 'symbolic-ref', '--quiet', 'HEAD'); + return false; + } catch { + return true; + } +} + +function unique(items: T[]): T[] { + return [...new Set(items)]; +} + +function buildDetachedWorkingTreeManifest(repoPath: string): SyncManifest { + const manifest = buildSyncManifest(git(repoPath, 'diff', '--name-status', '-M', 'HEAD')); + const untracked = git(repoPath, 'ls-files', '--others', '--exclude-standard') + .split('\n') + .filter(line => line.length > 0); + + return { + added: unique([...manifest.added, ...untracked]), + modified: unique(manifest.modified), + deleted: unique(manifest.deleted), + renamed: manifest.renamed, + }; +} + // v0.18.0 Step 5: source-scoped sync state helpers. When opts.sourceId // is set, read/write the per-source row instead of the global config // keys. These wrappers centralize the branch so every read/write site @@ -375,13 +402,21 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise 0 || + detachedWorkingTreeManifest.modified.length > 0 || + detachedWorkingTreeManifest.deleted.length > 0 || + detachedWorkingTreeManifest.renamed.length > 0); + + if (lastCommit === headCommit && !versionMismatch && !versionNeverSet && !hasDetachedWorkingTreeChanges) { return { status: 'up_to_date', fromCommit: lastCommit, @@ -466,6 +507,12 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise { // Structural assertion: the contract includes `embedded: number`. expect(typeof result.embedded).toBe('number'); }); + + test('detached HEAD skips git pull and ingests local working-tree files', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const seeded = await performSync(engine, { + repoPath, + noPull: true, + noEmbed: true, + noExtract: true, + }); + expect(seeded.status).toBe('first_sync'); + + execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'people/detached-local.md'), [ + '---', + 'type: person', + 'title: Detached Local', + '---', + '', + 'This file exists only in the detached working tree.', + ].join('\n')); + + const errors: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }; + + try { + const result = await performSync(engine, { + repoPath, + noEmbed: true, + noExtract: true, + }); + + expect(result.status).toBe('synced'); + expect(result.added).toBe(1); + expect(result.pagesAffected).toContain('people/detached-local'); + } finally { + console.error = originalError; + } + + expect(errors.join('\n')).toContain(`Detached HEAD on ${repoPath}; skipping git pull. Syncing from local working tree.`); + expect(errors.join('\n')).not.toContain('git pull failed'); + + const page = await engine.getPage('people/detached-local'); + expect(page).not.toBeNull(); + expect(page!.title).toBe('Detached Local'); + }); + + test('detached HEAD with --no-pull also ingests local working-tree files', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const seeded = await performSync(engine, { + repoPath, + noPull: true, + noEmbed: true, + noExtract: true, + }); + expect(seeded.status).toBe('first_sync'); + + execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'people/detached-nopull.md'), [ + '---', + 'type: person', + 'title: Detached NoPull', + '---', + '', + 'Only in detached working tree, --no-pull caller.', + ].join('\n')); + + const result = await performSync(engine, { + repoPath, + noPull: true, + noEmbed: true, + noExtract: true, + }); + + expect(result.status).toBe('synced'); + expect(result.added).toBe(1); + expect(result.pagesAffected).toContain('people/detached-nopull'); + + const page = await engine.getPage('people/detached-nopull'); + expect(page).not.toBeNull(); + expect(page!.title).toBe('Detached NoPull'); + }); }); describe('sync regression — #132 nested transaction deadlock', () => {