Skip to main content
Glama
overleaf-git-client.js8.04 kB
import { exec } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; import { promisify } from 'util'; const execAsync = promisify(exec); const DEFAULT_COMMIT_MESSAGE = 'Update via Overleaf MCP'; function resolveAuthorEnv() { const newEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; if (process.env.OVERLEAF_GIT_AUTHOR_NAME) { newEnv.GIT_AUTHOR_NAME = process.env.OVERLEAF_GIT_AUTHOR_NAME; newEnv.GIT_COMMITTER_NAME = process.env.OVERLEAF_GIT_AUTHOR_NAME; } if (process.env.OVERLEAF_GIT_AUTHOR_EMAIL) { newEnv.GIT_AUTHOR_EMAIL = process.env.OVERLEAF_GIT_AUTHOR_EMAIL; newEnv.GIT_COMMITTER_EMAIL = process.env.OVERLEAF_GIT_AUTHOR_EMAIL; } return newEnv; } function resolveRepoPath(basePath, targetPath) { const resolved = path.resolve(basePath, targetPath); if (!resolved.startsWith(path.resolve(basePath))) { throw new Error(`Path ${targetPath} escapes repository root`); } return resolved; } class OverleafGitClient { constructor(gitToken, projectId, tempDir = './temp') { this.gitToken = gitToken; this.projectId = projectId; this.tempDir = tempDir; this.repoUrl = `https://git:${gitToken}@git.overleaf.com/${projectId}`; this.localPath = path.join(tempDir, projectId); } async runGit(command, options = {}) { const env = { ...resolveAuthorEnv(), ...(options.env || {}) }; const { stdout, stderr } = await execAsync(command, { cwd: options.cwd || this.localPath, env, maxBuffer: options.maxBuffer || 10 * 1024 * 1024, }); return { stdout, stderr }; } async cloneOrPull() { try { await fs.access(this.localPath); await this.runGit('git pull'); } catch { await fs.mkdir(this.tempDir, { recursive: true }); await this.runGit(`git clone "${this.repoUrl}" "${this.localPath}"`, { cwd: this.tempDir, }); } } async listFiles(extension = '.tex') { await this.cloneOrPull(); const files = []; async function walk(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory() && entry.name !== '.git') { await walk(fullPath); } else if (entry.isFile() && (!extension || entry.name.endsWith(extension))) { files.push(fullPath); } } } await walk(this.localPath); return files.map(f => path.relative(this.localPath, f)); } async readFile(filePath) { await this.cloneOrPull(); const fullPath = resolveRepoPath(this.localPath, filePath); return await fs.readFile(fullPath, 'utf8'); } async writeFile(filePath, content) { await this.cloneOrPull(); const fullPath = resolveRepoPath(this.localPath, filePath); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, content, 'utf8'); } async stageChanges(filePath) { await this.cloneOrPull(); if (filePath) { await this.runGit(`git add "${filePath}"`); } else { await this.runGit('git add -A'); } } async hasPendingChanges() { await this.cloneOrPull(); const { stdout } = await this.runGit('git status --porcelain'); return stdout.trim().length > 0; } async commitAndPush( commitMessage = DEFAULT_COMMIT_MESSAGE, { push = true, allowEmpty = false, paths = [] } = {}, ) { await this.cloneOrPull(); const { stdout } = await this.runGit('git status --porcelain'); if (!stdout.trim() && !allowEmpty) { return { committed: false, pushed: false }; } const safeMessage = commitMessage.replace(/"/g, '\\"'); if (paths && paths.length > 0) { await this.runGit(`git add ${paths.map((p) => `"${p}"`).join(' ')}`); } else { await this.runGit('git add -A'); } await this.runGit(`git commit -m "${safeMessage}"${allowEmpty ? ' --allow-empty' : ''}`); if (push) { await this.runGit('git push'); } return { committed: true, pushed: push }; } async updateFile(filePath, content, commitMessage = DEFAULT_COMMIT_MESSAGE) { await this.writeFile(filePath, content); await this.stageChanges(filePath); return this.commitAndPush(commitMessage, { paths: [filePath] }); } async getSections(filePath) { const content = await this.readFile(filePath); const sections = []; const sectionRegex = /\\(part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?\{([^}]+)\}/g; let match; let lastIndex = 0; while ((match = sectionRegex.exec(content)) !== null) { const type = match[1]; const title = match[2]; const startIndex = match.index; if (sections.length > 0) { sections[sections.length - 1].content = content.substring(lastIndex + match[0].length, startIndex).trim(); } sections.push({ type, title, startIndex, content: '' }); lastIndex = startIndex; } if (sections.length > 0) { sections[sections.length - 1].content = content.substring(lastIndex + sections[sections.length - 1].title.length + 3).trim(); } return sections; } async getSection(filePath, sectionTitle) { const sections = await this.getSections(filePath); return sections.find(s => s.title === sectionTitle); } async getSectionsByType(filePath, type) { const sections = await this.getSections(filePath); return sections.filter(s => s.type === type); } async getLog({ limit = 20, path: logPath, since, until } = {}) { await this.cloneOrPull(); const args = [`-n ${Math.max(1, Math.min(limit, 200))}`, '--date=iso-strict', '--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s']; if (since) args.push(`--since="${since}"`); if (until) args.push(`--until="${until}"`); const pathPart = logPath ? ` -- "${logPath}"` : ''; const { stdout } = await this.runGit(`git log ${args.join(' ')}${pathPart}`); return stdout .trim() .split('\n') .filter(Boolean) .map((line) => { const [hash, author, email, date, subject] = line.split('\x1f'); return { hash, author, email, date, subject }; }); } async getDiff({ fromRef, toRef, paths = [], contextLines = 3, maxOutputChars = 200000 } = {}) { await this.cloneOrPull(); const safeContext = Math.max(0, Math.min(contextLines, 10)); const pathArgs = paths.length ? ` -- ${paths.map((p) => `"${p}"`).join(' ')}` : ''; let diffCmd; if (fromRef && toRef) { diffCmd = `git diff --no-color -U${safeContext} ${fromRef} ${toRef}${pathArgs}`; } else if (fromRef) { diffCmd = `git diff --no-color -U${safeContext} ${fromRef}${pathArgs}`; } else { diffCmd = `git diff --no-color -U${safeContext}${pathArgs}`; } const { stdout } = await this.runGit(diffCmd, { maxBuffer: maxOutputChars * 2 }); const diff = stdout || ''; const truncated = diff.length > maxOutputChars; return { diff: truncated ? diff.slice(0, maxOutputChars) : diff, truncated, }; } } export default OverleafGitClient;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/GhoshSrinjoy/overleaf-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server