diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 83445ef..587ba38 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -132,6 +132,14 @@ export interface GitActionResult { guidance?: string[]; } +export type GitPostFileChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed'; + +export interface GitPostFileChange { + status: GitPostFileChangeStatus; + path: string; + previousPath?: string; +} + type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo'; let gitEngineInstance: GitEngine | null = null; @@ -144,6 +152,8 @@ export function getGitEngine(): GitEngine { } export class GitEngine { + private readonly markdownExtensions = new Set(['.md', '.markdown', '.mdx']); + private readonly defaultGitignoreEntries = [ '.DS_Store', 'Thumbs.db', @@ -502,6 +512,66 @@ export class GitEngine { return { files, counts }; } + private normalizeRepoRelativePath(value: string): string { + return value.replace(/\\/g, '/').replace(/^\.\//, ''); + } + + private isPostsMarkdownPath(value: string): boolean { + const normalized = this.normalizeRepoRelativePath(value); + if (!normalized.startsWith('posts/')) { + return false; + } + + const extension = path.extname(normalized).toLowerCase(); + return this.markdownExtensions.has(extension); + } + + private parseNameStatusOutput(raw: string): GitPostFileChange[] { + const tokens = raw.split('\0').filter((token) => token.length > 0); + const changes: GitPostFileChange[] = []; + + let index = 0; + while (index < tokens.length) { + const statusToken = tokens[index++] ?? ''; + if (!statusToken) { + continue; + } + + if (statusToken.startsWith('R')) { + const previousPathRaw = tokens[index++] ?? ''; + const nextPathRaw = tokens[index++] ?? ''; + const previousPath = this.normalizeRepoRelativePath(previousPathRaw); + const pathValue = this.normalizeRepoRelativePath(nextPathRaw); + + if (this.isPostsMarkdownPath(previousPath) || this.isPostsMarkdownPath(pathValue)) { + changes.push({ + status: 'renamed', + path: pathValue, + previousPath, + }); + } + continue; + } + + const filePathRaw = tokens[index++] ?? ''; + const filePath = this.normalizeRepoRelativePath(filePathRaw); + if (!this.isPostsMarkdownPath(filePath)) { + continue; + } + + const statusCode = statusToken[0] ?? ''; + if (statusCode === 'A') { + changes.push({ status: 'added', path: filePath }); + } else if (statusCode === 'M') { + changes.push({ status: 'modified', path: filePath }); + } else if (statusCode === 'D') { + changes.push({ status: 'deleted', path: filePath }); + } + } + + return changes; + } + private async getStatusViaCli(projectPath: string): Promise { const raw = await this.runGitCli(projectPath, ['status', '--porcelain=v1', '-z']); return this.parsePorcelainStatus(raw); @@ -1235,6 +1305,54 @@ export class GitEngine { } } + async getHeadCommit(projectPath: string): Promise { + const git = this.createNonInteractiveGit(projectPath); + try { + const output = await git.raw(['rev-parse', 'HEAD']); + const commit = output.trim(); + return commit.length > 0 ? commit : null; + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + try { + const output = await this.runGitCli(projectPath, ['rev-parse', 'HEAD']); + const commit = output.trim(); + return commit.length > 0 ? commit : null; + } catch { + return null; + } + } + return null; + } + } + + async getChangedPostFilesBetween(projectPath: string, fromCommit: string, toCommit: string): Promise { + const fromRef = fromCommit.trim(); + const toRef = toCommit.trim(); + if (!fromRef || !toRef || fromRef === toRef) { + return []; + } + + const git = this.createNonInteractiveGit(projectPath); + const args = ['diff', '--name-status', '--find-renames', '-z', `${fromRef}..${toRef}`, '--', 'posts']; + + try { + const output = await git.raw(args); + return this.parseNameStatusOutput(output); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + if (this.isSpawnBadFileDescriptorError(message)) { + try { + const output = await this.runGitCli(projectPath, args); + return this.parseNameStatusOutput(output); + } catch { + return []; + } + } + return []; + } + } + async pull(projectPath: string): Promise { const git = this.createNonInteractiveGit(projectPath); try { diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 737ab73..4a2a9fa 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -72,6 +72,21 @@ export interface PaginationOptions { offset?: number; } +export type GitPostFileChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed'; + +export interface GitPostFileChange { + status: GitPostFileChangeStatus; + path: string; + previousPath?: string; +} + +export interface PublishedPostReconcileResult { + created: number; + updated: number; + deleted: number; + processedFiles: number; +} + export class PostEngine extends EventEmitter { private currentProjectId: string = 'default'; private searchLanguage: SupportedLanguage = 'english'; @@ -248,6 +263,40 @@ export class PostEngine extends EventEmitter { return crypto.createHash('md5').update(content).digest('hex'); } + private normalizePathForCompare(filePath: string): string { + return path.resolve(filePath).replace(/\\/g, '/'); + } + + private isMarkdownPostPath(value: string): boolean { + const normalized = value.replace(/\\/g, '/').replace(/^\.\//, ''); + if (!normalized.startsWith('posts/')) { + return false; + } + + const extension = path.extname(normalized).toLowerCase(); + return extension === '.md' || extension === '.markdown' || extension === '.mdx'; + } + + private async ensureUniquePostIdentity(id: string, slug: string): Promise<{ id: string; slug: string }> { + const uniqueId = id.trim().length > 0 ? id.trim() : uuidv4(); + const safeSlug = slug.trim().length > 0 ? slug.trim() : await this.generateUniqueSlug('untitled'); + + const db = getDatabase().getLocal(); + + const existingById = await db + .select({ id: posts.id }) + .from(posts) + .where(eq(posts.id, uniqueId)) + .get(); + + const finalId = existingById ? uuidv4() : uniqueId; + + const slugAvailable = await this.isSlugAvailable(safeSlug); + const finalSlug = slugAvailable ? safeSlug : await this.generateUniqueSlug(safeSlug); + + return { id: finalId, slug: finalSlug }; + } + private async writePostFile(post: PostData): Promise { const metadata: Record = { id: post.id, @@ -1104,6 +1153,233 @@ export class PostEngine extends EventEmitter { console.log(`Rebuilt FTS index for ${allPosts.length} posts`); } + async reconcilePublishedPostsFromGitChanges( + projectPath: string, + changes: GitPostFileChange[], + ): Promise { + const db = getDatabase().getLocal(); + const normalizedProjectPath = path.resolve(projectPath); + + const relevantChanges = changes.filter((change) => { + if (!this.isMarkdownPostPath(change.path)) { + return false; + } + if (change.status === 'renamed' && change.previousPath && !this.isMarkdownPostPath(change.previousPath) && !this.isMarkdownPostPath(change.path)) { + return false; + } + return true; + }); + + if (relevantChanges.length === 0) { + return { created: 0, updated: 0, deleted: 0, processedFiles: 0 }; + } + + const projectPosts = await db + .select() + .from(posts) + .where(eq(posts.projectId, this.currentProjectId)) + .all(); + + const publishedRows = projectPosts.filter((row) => row.status === 'published' && Boolean(row.filePath)); + const publishedByFilePath = new Map(); + for (const row of publishedRows) { + if (!row.filePath) { + continue; + } + publishedByFilePath.set(this.normalizePathForCompare(row.filePath), row); + } + + let created = 0; + let updated = 0; + let deleted = 0; + let processedFiles = 0; + + for (const change of relevantChanges) { + const absolutePath = this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.path)); + const previousAbsolutePath = change.previousPath + ? this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.previousPath)) + : null; + + if (change.status === 'deleted') { + const existingPublished = publishedByFilePath.get(absolutePath); + if (!existingPublished) { + continue; + } + + await db.delete(postLinks).where(eq(postLinks.sourcePostId, existingPublished.id)); + await db.delete(postLinks).where(eq(postLinks.targetPostId, existingPublished.id)); + await db.delete(posts).where(eq(posts.id, existingPublished.id)); + await this.deleteFTSIndex(existingPublished.id); + this.emit('postDeleted', existingPublished.id); + + publishedByFilePath.delete(absolutePath); + deleted += 1; + processedFiles += 1; + continue; + } + + const existingPublished = previousAbsolutePath + ? (publishedByFilePath.get(previousAbsolutePath) || publishedByFilePath.get(absolutePath)) + : publishedByFilePath.get(absolutePath); + + const fileData = await this.readPostFile(absolutePath); + if (!fileData) { + continue; + } + + if (existingPublished) { + const nextSlugCandidate = fileData.slug || existingPublished.slug; + const nextSlug = await this.isSlugAvailable(nextSlugCandidate, existingPublished.id) + ? nextSlugCandidate + : await this.generateUniqueSlug(nextSlugCandidate, existingPublished.id); + + const checksum = this.calculateChecksum(fileData.content); + const nextPublishedAt = fileData.publishedAt || existingPublished.publishedAt || fileData.updatedAt; + + await db.update(posts) + .set({ + title: fileData.title, + slug: nextSlug, + excerpt: fileData.excerpt, + content: null, + status: 'published', + author: fileData.author, + createdAt: fileData.createdAt, + updatedAt: fileData.updatedAt, + publishedAt: nextPublishedAt, + filePath: absolutePath, + checksum, + tags: JSON.stringify(fileData.tags), + categories: JSON.stringify(fileData.categories), + }) + .where(eq(posts.id, existingPublished.id)); + + await this.updateFTSIndex({ + id: existingPublished.id, + projectId: existingPublished.projectId, + title: fileData.title, + content: fileData.content, + excerpt: fileData.excerpt, + tags: fileData.tags, + categories: fileData.categories, + }); + + const updatedPost: PostData = { + id: existingPublished.id, + projectId: existingPublished.projectId, + title: fileData.title, + slug: nextSlug, + excerpt: fileData.excerpt || undefined, + content: fileData.content, + status: 'published', + author: fileData.author || undefined, + createdAt: fileData.createdAt, + updatedAt: fileData.updatedAt, + publishedAt: nextPublishedAt || undefined, + tags: fileData.tags, + categories: fileData.categories, + }; + + this.emit('postUpdated', updatedPost); + + if (previousAbsolutePath) { + publishedByFilePath.delete(previousAbsolutePath); + } + + publishedByFilePath.set(absolutePath, { + ...existingPublished, + title: updatedPost.title, + slug: updatedPost.slug, + excerpt: updatedPost.excerpt ?? null, + content: null, + status: 'published', + author: updatedPost.author ?? null, + createdAt: updatedPost.createdAt, + updatedAt: updatedPost.updatedAt, + publishedAt: updatedPost.publishedAt ?? null, + filePath: absolutePath, + checksum, + tags: JSON.stringify(updatedPost.tags), + categories: JSON.stringify(updatedPost.categories), + }); + + updated += 1; + processedFiles += 1; + continue; + } + + if (change.status !== 'added') { + continue; + } + + const identity = await this.ensureUniquePostIdentity(fileData.id, fileData.slug); + const checksum = this.calculateChecksum(fileData.content); + const publishedAt = fileData.publishedAt || fileData.updatedAt; + const newPostRow: NewPost = { + id: identity.id, + projectId: this.currentProjectId, + title: fileData.title, + slug: identity.slug, + excerpt: fileData.excerpt, + content: null, + status: 'published', + author: fileData.author, + createdAt: fileData.createdAt, + updatedAt: fileData.updatedAt, + publishedAt, + filePath: absolutePath, + checksum, + tags: JSON.stringify(fileData.tags), + categories: JSON.stringify(fileData.categories), + }; + + await db.insert(posts).values(newPostRow); + await this.updateFTSIndex({ + id: identity.id, + projectId: this.currentProjectId, + title: fileData.title, + content: fileData.content, + excerpt: fileData.excerpt, + tags: fileData.tags, + categories: fileData.categories, + }); + + const createdPost: PostData = { + id: identity.id, + projectId: this.currentProjectId, + title: fileData.title, + slug: identity.slug, + excerpt: fileData.excerpt || undefined, + content: fileData.content, + status: 'published', + author: fileData.author || undefined, + createdAt: fileData.createdAt, + updatedAt: fileData.updatedAt, + publishedAt: publishedAt || undefined, + tags: fileData.tags, + categories: fileData.categories, + }; + + this.emit('postCreated', createdPost); + publishedByFilePath.set(absolutePath, { + ...newPostRow, + excerpt: newPostRow.excerpt ?? null, + content: null, + author: newPostRow.author ?? null, + } as Post); + + created += 1; + processedFiles += 1; + } + + return { + created, + updated, + deleted, + processedFiles, + }; + } + /** * Reindex all text for full-text search. * Runs as a background task with progress updates. diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 0f7456c..c84f610 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -171,7 +171,39 @@ export function registerIpcHandlers(): void { safeHandle('git:pull', async (_, projectPath: string) => { const engine = getGitEngine(); - return engine.pull(projectPath); + const beforeHead = await engine.getHeadCommit(projectPath); + const pullResult = await engine.pull(projectPath); + + if (!pullResult.success) { + return pullResult; + } + + const afterHead = await engine.getHeadCommit(projectPath); + if (!beforeHead || !afterHead || beforeHead === afterHead) { + return pullResult; + } + + const changedPostFiles = await engine.getChangedPostFilesBetween(projectPath, beforeHead, afterHead); + if (changedPostFiles.length === 0) { + return pullResult; + } + + try { + const projectEngine = getProjectEngine(); + const project = await projectEngine.getActiveProject(); + const postEngine = getPostEngine(); + + if (project) { + const dataDir = projectEngine.getDataDir(project.id, project.dataPath); + postEngine.setProjectContext(project.id, dataDir); + } + + await postEngine.reconcilePublishedPostsFromGitChanges(projectPath, changedPostFiles); + } catch (error) { + console.error('Failed to reconcile published posts after git pull:', error); + } + + return pullResult; }); safeHandle('git:push', async (_, projectPath: string) => { diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index 381476b..c2d2eba 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -2957,4 +2957,87 @@ Content with [link](/posts/other-post)`); expect(ftsInserts.length).toBeGreaterThanOrEqual(2); }); }); + + describe('reconcilePublishedPostsFromGitChanges', () => { + it('should process added and modified markdown files as published posts', async () => { + postEngine.setProjectContext('default', '/repo'); + + const existingPublishedPath = '/repo/posts/2026/02/existing-post.md'; + mockPosts.set('published-existing', { + id: 'published-existing', + projectId: 'default', + title: 'Existing Post', + slug: 'existing-post', + excerpt: null, + content: null, + status: 'published', + author: null, + createdAt: new Date('2026-02-01T10:00:00.000Z'), + updatedAt: new Date('2026-02-01T10:00:00.000Z'), + publishedAt: new Date('2026-02-01T10:00:00.000Z'), + filePath: existingPublishedPath, + checksum: 'old-checksum', + tags: '[]', + categories: '[]', + }); + + mockFiles.set(existingPublishedPath, `---\nid: published-existing\ntitle: Existing Post Updated\nslug: existing-post\nstatus: published\ncreatedAt: 2026-02-01T10:00:00.000Z\nupdatedAt: 2026-02-22T10:00:00.000Z\ntags:\n - synced\ncategories:\n - updates\n---\nUpdated content`); + mockFiles.set('/repo/posts/2026/02/new-from-pull.md', `---\nid: new-from-pull-id\ntitle: New From Pull\nslug: new-from-pull\nstatus: published\ncreatedAt: 2026-02-22T09:00:00.000Z\nupdatedAt: 2026-02-22T09:00:00.000Z\ntags:\n - new\ncategories:\n - updates\n---\nBrand new post content`); + + const emitSpy = vi.spyOn(postEngine, 'emit'); + + const result = await postEngine.reconcilePublishedPostsFromGitChanges('/repo', [ + { status: 'modified', path: 'posts/2026/02/existing-post.md' }, + { status: 'added', path: 'posts/2026/02/new-from-pull.md' }, + ]); + + expect(mockLocalDb.update).toHaveBeenCalled(); + expect(mockLocalDb.insert).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith('postUpdated', expect.objectContaining({ id: 'published-existing' })); + expect(emitSpy).toHaveBeenCalledWith('postCreated', expect.objectContaining({ slug: 'new-from-pull', status: 'published' })); + expect(result.created).toBe(1); + expect(result.updated).toBe(1); + expect(result.deleted).toBe(0); + expect(result.processedFiles).toBe(2); + }); + + it('should ignore draft posts when matching file paths appear in git changes', async () => { + postEngine.setProjectContext('default', '/repo'); + + const draftPath = '/repo/posts/2026/02/draft-post.md'; + mockPosts.set('draft-post', { + id: 'draft-post', + projectId: 'default', + title: 'Draft Post', + slug: 'draft-post', + excerpt: null, + content: 'Draft content', + status: 'draft', + author: null, + createdAt: new Date('2026-02-01T10:00:00.000Z'), + updatedAt: new Date('2026-02-01T10:00:00.000Z'), + publishedAt: null, + filePath: draftPath, + checksum: 'draft-checksum', + tags: '[]', + categories: '[]', + }); + + mockFiles.set(draftPath, `---\nid: draft-post\ntitle: Draft Post From File\nslug: draft-post\nstatus: published\ncreatedAt: 2026-02-01T10:00:00.000Z\nupdatedAt: 2026-02-22T10:00:00.000Z\n---\nShould be ignored`); + + const emitSpy = vi.spyOn(postEngine, 'emit'); + + const result = await postEngine.reconcilePublishedPostsFromGitChanges('/repo', [ + { status: 'modified', path: 'posts/2026/02/draft-post.md' }, + ]); + + expect(mockLocalDb.update).not.toHaveBeenCalled(); + expect(mockLocalDb.insert).not.toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalledWith('postUpdated', expect.objectContaining({ id: 'draft-post' })); + expect(result.created).toBe(0); + expect(result.updated).toBe(0); + expect(result.deleted).toBe(0); + expect(result.processedFiles).toBe(0); + }); + }); }); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 3efe7c9..9dddec1 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -44,6 +44,7 @@ const mockPostEngine = { on: vi.fn(), setProjectContext: vi.fn(), setSearchLanguage: vi.fn(), + reconcilePublishedPostsFromGitChanges: vi.fn(), createPost: vi.fn(), updatePost: vi.fn(), deletePost: vi.fn(), @@ -159,6 +160,8 @@ const mockPostMediaEngine = { const mockGitEngine = { checkAvailability: vi.fn(), + getHeadCommit: vi.fn(), + getChangedPostFilesBetween: vi.fn(), getRepoState: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), @@ -549,12 +552,58 @@ describe('IPC Handlers', () => { }); describe('git:pull', () => { - it('should pass project path to GitEngine.pull', async () => { + it('should reconcile published posts from pulled post file changes when pull succeeds', async () => { + mockGitEngine.getHeadCommit + .mockResolvedValueOnce('before-head') + .mockResolvedValueOnce('after-head'); + mockGitEngine.pull.mockResolvedValue({ success: true }); + mockGitEngine.getChangedPostFilesBetween.mockResolvedValue([ + { status: 'modified', path: 'posts/2026/02/existing.md' }, + { status: 'added', path: 'posts/2026/02/new-post.md' }, + ]); + mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({ + created: 1, + updated: 1, + deleted: 0, + processedFiles: 2, + }); + + const result = await invokeHandler('git:pull', '/repo'); + + expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(1, '/repo'); + expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo'); + expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo'); + expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head'); + expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).toHaveBeenCalledWith('/repo', [ + { status: 'modified', path: 'posts/2026/02/existing.md' }, + { status: 'added', path: 'posts/2026/02/new-post.md' }, + ]); + expect(result).toEqual({ success: true }); + }); + + it('should skip reconciliation when pull fails', async () => { + mockGitEngine.getHeadCommit.mockResolvedValue('before-head'); + mockGitEngine.pull.mockResolvedValue({ success: false, code: 'conflict' }); + + const result = await invokeHandler('git:pull', '/repo'); + + expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo'); + expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled(); + expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled(); + expect(result).toEqual({ success: false, code: 'conflict' }); + }); + + it('should skip reconciliation when pull does not change HEAD', async () => { + mockGitEngine.getHeadCommit + .mockResolvedValueOnce('same-head') + .mockResolvedValueOnce('same-head'); mockGitEngine.pull.mockResolvedValue({ success: true }); const result = await invokeHandler('git:pull', '/repo'); expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo'); + expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled(); + expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled(); expect(result).toEqual({ success: true }); }); });