feat: git pull now updates db
This commit is contained in:
@@ -132,6 +132,14 @@ export interface GitActionResult {
|
|||||||
guidance?: string[];
|
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';
|
type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo';
|
||||||
|
|
||||||
let gitEngineInstance: GitEngine | null = null;
|
let gitEngineInstance: GitEngine | null = null;
|
||||||
@@ -144,6 +152,8 @@ export function getGitEngine(): GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GitEngine {
|
export class GitEngine {
|
||||||
|
private readonly markdownExtensions = new Set(['.md', '.markdown', '.mdx']);
|
||||||
|
|
||||||
private readonly defaultGitignoreEntries = [
|
private readonly defaultGitignoreEntries = [
|
||||||
'.DS_Store',
|
'.DS_Store',
|
||||||
'Thumbs.db',
|
'Thumbs.db',
|
||||||
@@ -502,6 +512,66 @@ export class GitEngine {
|
|||||||
return { files, counts };
|
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<GitStatusDto> {
|
private async getStatusViaCli(projectPath: string): Promise<GitStatusDto> {
|
||||||
const raw = await this.runGitCli(projectPath, ['status', '--porcelain=v1', '-z']);
|
const raw = await this.runGitCli(projectPath, ['status', '--porcelain=v1', '-z']);
|
||||||
return this.parsePorcelainStatus(raw);
|
return this.parsePorcelainStatus(raw);
|
||||||
@@ -1235,6 +1305,54 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getHeadCommit(projectPath: string): Promise<string | null> {
|
||||||
|
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<GitPostFileChange[]> {
|
||||||
|
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<GitActionResult> {
|
async pull(projectPath: string): Promise<GitActionResult> {
|
||||||
const git = this.createNonInteractiveGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ export interface PaginationOptions {
|
|||||||
offset?: number;
|
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 {
|
export class PostEngine extends EventEmitter {
|
||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
private searchLanguage: SupportedLanguage = 'english';
|
private searchLanguage: SupportedLanguage = 'english';
|
||||||
@@ -248,6 +263,40 @@ export class PostEngine extends EventEmitter {
|
|||||||
return crypto.createHash('md5').update(content).digest('hex');
|
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<string> {
|
private async writePostFile(post: PostData): Promise<string> {
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@@ -1104,6 +1153,233 @@ export class PostEngine extends EventEmitter {
|
|||||||
console.log(`Rebuilt FTS index for ${allPosts.length} posts`);
|
console.log(`Rebuilt FTS index for ${allPosts.length} posts`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reconcilePublishedPostsFromGitChanges(
|
||||||
|
projectPath: string,
|
||||||
|
changes: GitPostFileChange[],
|
||||||
|
): Promise<PublishedPostReconcileResult> {
|
||||||
|
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<string, Post>();
|
||||||
|
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.
|
* Reindex all text for full-text search.
|
||||||
* Runs as a background task with progress updates.
|
* Runs as a background task with progress updates.
|
||||||
|
|||||||
@@ -171,7 +171,39 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
safeHandle('git:pull', async (_, projectPath: string) => {
|
safeHandle('git:pull', async (_, projectPath: string) => {
|
||||||
const engine = getGitEngine();
|
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) => {
|
safeHandle('git:push', async (_, projectPath: string) => {
|
||||||
|
|||||||
@@ -2957,4 +2957,87 @@ Content with [link](/posts/other-post)`);
|
|||||||
expect(ftsInserts.length).toBeGreaterThanOrEqual(2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const mockPostEngine = {
|
|||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
setProjectContext: vi.fn(),
|
setProjectContext: vi.fn(),
|
||||||
setSearchLanguage: vi.fn(),
|
setSearchLanguage: vi.fn(),
|
||||||
|
reconcilePublishedPostsFromGitChanges: vi.fn(),
|
||||||
createPost: vi.fn(),
|
createPost: vi.fn(),
|
||||||
updatePost: vi.fn(),
|
updatePost: vi.fn(),
|
||||||
deletePost: vi.fn(),
|
deletePost: vi.fn(),
|
||||||
@@ -159,6 +160,8 @@ const mockPostMediaEngine = {
|
|||||||
|
|
||||||
const mockGitEngine = {
|
const mockGitEngine = {
|
||||||
checkAvailability: vi.fn(),
|
checkAvailability: vi.fn(),
|
||||||
|
getHeadCommit: vi.fn(),
|
||||||
|
getChangedPostFilesBetween: vi.fn(),
|
||||||
getRepoState: vi.fn(),
|
getRepoState: vi.fn(),
|
||||||
getStatus: vi.fn(),
|
getStatus: vi.fn(),
|
||||||
getDiff: vi.fn(),
|
getDiff: vi.fn(),
|
||||||
@@ -549,12 +552,58 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('git:pull', () => {
|
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 });
|
mockGitEngine.pull.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
const result = await invokeHandler('git:pull', '/repo');
|
const result = await invokeHandler('git:pull', '/repo');
|
||||||
|
|
||||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||||
|
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
||||||
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user