import { simpleGit, SimpleGit } from 'simple-git';
import { promises as fs } from 'fs';
import path from 'path';
import { glob } from 'glob';
import matter from 'gray-matter';
import { VaultProvider, Note, SearchResult } from './provider.js';
import { config } from '../config.js';
export class GitVaultProvider implements VaultProvider {
private git: SimpleGit;
private vaultPath: string;
private initialized: boolean = false;
constructor(vaultPath: string = './vault') {
this.vaultPath = path.resolve(vaultPath);
this.git = simpleGit();
}
async initialize(): Promise<void> {
if (this.initialized) return;
console.log('π¦ Initializing Git vault...');
// Check if vault directory exists
try {
await fs.access(this.vaultPath);
console.log('π Vault directory exists, pulling latest changes...');
this.git = simpleGit(this.vaultPath);
// Set initialized BEFORE pulling to prevent infinite loop
this.initialized = true;
// Pull latest changes directly without calling sync()
try {
await this.git.pull('origin', config.gitBranch, { '--rebase': 'true' });
console.log('π₯ Pulled latest changes from remote');
} catch (error) {
console.warn('β οΈ Warning during initial pull:', error);
}
} catch {
// Clone the repository
console.log('π₯ Cloning vault repository...');
await fs.mkdir(path.dirname(this.vaultPath), { recursive: true });
const repoUrl = this.getAuthenticatedRepoUrl();
await simpleGit().clone(repoUrl, this.vaultPath, ['--branch', config.gitBranch]);
this.git = simpleGit(this.vaultPath);
this.initialized = true;
}
console.log('β
Vault initialized successfully');
}
async read(notePath: string): Promise<Note> {
await this.ensureInitialized();
const fullPath = this.resolvePath(notePath);
const content = await fs.readFile(fullPath, 'utf-8');
// Parse frontmatter if it exists
const parsed = matter(content);
return {
path: notePath,
content: parsed.content,
frontmatter: Object.keys(parsed.data).length > 0 ? parsed.data : undefined,
};
}
async write(notePath: string, content: string, frontmatter?: Record<string, any>): Promise<void> {
await this.ensureInitialized();
const fullPath = this.resolvePath(notePath);
// Ensure directory exists
await fs.mkdir(path.dirname(fullPath), { recursive: true });
// Combine frontmatter and content
let finalContent = content;
if (frontmatter && Object.keys(frontmatter).length > 0) {
finalContent = matter.stringify(content, frontmatter);
}
// Write the file
await fs.writeFile(fullPath, finalContent, 'utf-8');
// Commit and push
await this.commitAndPush(`Update ${notePath}`);
}
async delete(notePath: string): Promise<void> {
await this.ensureInitialized();
const fullPath = this.resolvePath(notePath);
await fs.unlink(fullPath);
// Commit and push
await this.git.rm(notePath);
await this.commitAndPush(`Delete ${notePath}`);
}
async search(query: string, options?: { limit?: number }): Promise<SearchResult[]> {
await this.ensureInitialized();
const files = await this.list('**/*.md');
const results: SearchResult[] = [];
const limit = options?.limit ?? 50;
const searchRegex = new RegExp(query, 'gi');
for (const file of files) {
try {
const { content } = await this.read(file);
const matches = content.match(searchRegex);
if (matches) {
results.push({
path: file,
content: this.getContextSnippet(content, query),
matches: matches.length,
});
if (results.length >= limit) break;
}
} catch (error) {
// Skip files that can't be read
continue;
}
}
return results.sort((a, b) => (b.matches ?? 0) - (a.matches ?? 0));
}
async list(pattern: string = '**/*.md'): Promise<string[]> {
await this.ensureInitialized();
const files = await glob(pattern, {
cwd: this.vaultPath,
nodir: true,
ignore: ['**/node_modules/**', '**/.git/**', '**/.obsidian/**'],
});
return files.map(file => file.replace(/\\/g, '/'));
}
async sync(): Promise<void> {
await this.ensureInitialized();
try {
// Pull latest changes
await this.git.pull('origin', config.gitBranch, { '--rebase': 'true' });
console.log('π₯ Pulled latest changes from remote');
} catch (error) {
console.warn('β οΈ Warning during sync:', error);
}
}
getVaultPath(): string {
return this.vaultPath;
}
// Private helper methods
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
private resolvePath(notePath: string): string {
// Normalize path and ensure .md extension
let normalizedPath = notePath.replace(/\\/g, '/');
if (!normalizedPath.endsWith('.md')) {
normalizedPath += '.md';
}
const fullPath = path.join(this.vaultPath, normalizedPath);
// Security: prevent path traversal
if (!fullPath.startsWith(this.vaultPath)) {
throw new Error('Invalid path: Path traversal detected');
}
return fullPath;
}
private getAuthenticatedRepoUrl(): string {
const url = new URL(config.gitRepoUrl);
// Add token to URL for authentication
if (config.gitToken) {
url.username = config.gitToken;
url.password = 'x-oauth-basic';
}
return url.toString();
}
private async commitAndPush(message: string): Promise<void> {
try {
await this.git.add('.');
await this.git.commit(message);
await this.git.push('origin', config.gitBranch);
console.log(`β
Committed and pushed: ${message}`);
} catch (error) {
console.error('β Error committing changes:', error);
throw error;
}
}
private getContextSnippet(content: string, query: string, contextLength: number = 100): string {
const index = content.toLowerCase().indexOf(query.toLowerCase());
if (index === -1) return content.substring(0, contextLength);
const start = Math.max(0, index - contextLength / 2);
const end = Math.min(content.length, index + query.length + contextLength / 2);
let snippet = content.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < content.length) snippet = snippet + '...';
return snippet;
}
}