/** * Git operations using dugite * This module provides git log, checkout, revert functionality */ import { i18n } from '@services/libs/i18n'; import { exec as gitExec } from 'dugite'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { defaultGitInfo } from './defaultGitInfo'; import type { IGitLogOptions, IGitLogResult } from './interface'; /** * Helper to create git environment variables for commit operations * This ensures commits work in environments without git config (like CI) */ function getGitCommitEnvironment(username: string = defaultGitInfo.gitUserName, email: string = defaultGitInfo.email) { return { ...process.env, GIT_AUTHOR_NAME: username, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: username, GIT_COMMITTER_EMAIL: email, }; } /** * Get git log with pagination */ export async function getGitLog(repoPath: string, options: IGitLogOptions = {}): Promise { const { page = 0, pageSize = 100, searchQuery } = options; const skip = page * pageSize; // Check for uncommitted changes const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain'], repoPath); const hasUncommittedChanges = statusResult.stdout.trim().length > 0; // Build git log command arguments const logArguments = [ 'log', '--all', '--pretty=format:%H|%P|%D|%s|%ci|%an|%ae|%ai', '--date=iso', `--skip=${skip}`, `--max-count=${pageSize}`, ]; // Add search query if provided if (searchQuery) { logArguments.push(`--grep=${searchQuery}`); } const result = await gitExec(logArguments, repoPath); if (result.exitCode !== 0) { throw new Error(`Git log failed: ${result.stderr}`); } // Get current branch const branchResult = await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath); const currentBranch = branchResult.stdout.trim(); // Get total count const countArguments = ['rev-list', '--all', '--count']; if (searchQuery) { countArguments.push(`--grep=${searchQuery}`); } const countResult = await gitExec(countArguments, repoPath); const totalCount = Number.parseInt(countResult.stdout.trim(), 10); // Parse log output const entries = result.stdout .trim() .split('\n') .filter((line: string) => line.length > 0) .map((line: string) => { const [hash, parents, references, message, committerDate, authorName, authorEmail, authorDate] = line.split('|'); // Extract branch from refs (e.g., "HEAD -> main, origin/main") let branch = ''; if (references) { const branchMatch = references.match(/(?:HEAD -> |origin\/)?([^,\s]+)/); branch = branchMatch ? branchMatch[1] : ''; } return { hash, parents: parents.split(' ').filter((p: string) => p.length > 0), branch, message, committerDate, author: { name: authorName, email: authorEmail || undefined, }, authorDate: authorDate || undefined, }; }); // Add uncommitted changes as first entry if any if (hasUncommittedChanges && page === 0) { const now = new Date().toISOString(); entries.unshift({ hash: '', parents: [], branch: currentBranch, message: i18n.t('ContextMenu.UncommittedChanges'), committerDate: now, author: { name: 'Local', email: undefined, }, authorDate: now, }); } return { entries, currentBranch, totalCount: totalCount + (hasUncommittedChanges ? 1 : 0), }; } /** * Parse git status code to file status */ function parseGitStatusCode(statusCode: string): import('./interface').GitFileStatus { const index = statusCode[0]; const workTree = statusCode[1]; // Check for specific patterns if (statusCode === '??') return 'untracked'; if (index === 'A' || workTree === 'A') return 'added'; if (index === 'D' || workTree === 'D') return 'deleted'; if (index === 'R' || workTree === 'R') return 'renamed'; if (index === 'C' || workTree === 'C') return 'copied'; if (index === 'M' || workTree === 'M') return 'modified'; return 'unknown'; } /** * Get files changed in a specific commit * If commitHash is empty, returns uncommitted changes */ export async function getCommitFiles(repoPath: string, commitHash: string): Promise> { // Handle uncommitted changes if (!commitHash || commitHash === '') { const result = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain'], repoPath); if (result.exitCode !== 0) { throw new Error(`Failed to get uncommitted files: ${result.stderr}`); } return result.stdout .split(/\r?\n/) .map(line => line.trimEnd()) .filter((line: string) => line.length > 0) .map((line: string) => { if (line.length <= 3) { return { path: line.trim(), status: 'unknown' as import('./interface').GitFileStatus }; } // Parse git status format: "XY filename" // XY is two-letter status code, filename starts at position 3 const statusCode = line.slice(0, 2); const rawPath = line.slice(3); // Handle rename format: "old -> new" – we want the new path const renameParts = rawPath.split(' -> '); const filePath = renameParts[renameParts.length - 1].trim(); return { path: filePath, status: parseGitStatusCode(statusCode), }; }) .filter((item) => item.path.length > 0); } // For committed changes, use diff-tree with --name-status to get file status const result = await gitExec( ['-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-status', '-r', commitHash], repoPath, ); if (result.exitCode !== 0) { throw new Error(`Failed to get commit files: ${result.stderr}`); } return result.stdout .trim() .split('\n') .filter((line: string) => line.length > 0) .map((line: string) => { // Format: "STATUS\tFILENAME" or "STATUS\tOLDNAME\tNEWNAME" for renames const parts = line.split('\t'); const statusChar = parts[0]; const filePath = parts[parts.length - 1]; // Use last part for renames let status: import('./interface').GitFileStatus = 'unknown'; if (statusChar === 'A') status = 'added'; else if (statusChar === 'M') status = 'modified'; else if (statusChar === 'D') status = 'deleted'; else if (statusChar.startsWith('R')) status = 'renamed'; else if (statusChar.startsWith('C')) status = 'copied'; return { path: filePath, status }; }); } /** * Get diff for a specific file in a commit * @param maxLines - Maximum number of lines to return (default: 500) * @param maxChars - Maximum number of characters to return (default: 10000) */ const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']; export async function getFileDiff( repoPath: string, commitHash: string, filePath: string, maxLines = 500, maxChars = 10000, ): Promise { if (!commitHash) { const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain', '--', filePath], repoPath); if (statusResult.exitCode !== 0) { throw new Error(`Failed to get status for working tree diff: ${statusResult.stderr}`); } const statusLine = statusResult.stdout.trim().split(/\r?\n/).find(Boolean) ?? ''; const statusCode = statusLine.slice(0, 2); const isUntracked = statusCode === '??'; const isImage = IMAGE_EXTENSIONS.some(extension => filePath.toLowerCase().endsWith(extension)); if (isUntracked) { if (isImage) { return { content: `Binary files /dev/null and b/${filePath} differ`, isTruncated: false, }; } try { const content = await fs.readFile(path.join(repoPath, filePath), 'utf-8'); const diff = [ `diff --git a/${filePath} b/${filePath}`, 'new file mode 100644', '--- /dev/null', `+++ b/${filePath}`, ...content.split(/\r?\n/).map(line => `+${line}`), ].join('\n'); return truncateDiff(diff, maxLines, maxChars); } catch (error) { console.error('[getFileDiff] Failed to read untracked file content:', error); return { content: createBinaryDiffPlaceholder(filePath), isTruncated: false, }; } } // Check if file is deleted const isDeleted = statusCode.includes('D'); if (isDeleted) { // For deleted files, show the deletion diff const result = await gitExec( ['diff', 'HEAD', '--', filePath], repoPath, ); if (result.exitCode !== 0) { // If diff fails, try to show the file content from HEAD const headContent = await gitExec( ['show', `HEAD:${filePath}`], repoPath, ); if (headContent.exitCode === 0) { const diff = [ `diff --git a/${filePath} b/${filePath}`, 'deleted file mode 100644', `--- a/${filePath}`, '+++ /dev/null', ...headContent.stdout.split(/\r?\n/).map(line => `-${line}`), ].join('\n'); return truncateDiff(diff, maxLines, maxChars); } throw new Error(`Failed to get diff for deleted file: ${result.stderr}`); } return truncateDiff(result.stdout, maxLines, maxChars); } const result = await gitExec( ['diff', 'HEAD', '--', filePath], repoPath, ); if (result.exitCode !== 0) { throw new Error(`Failed to get working tree diff: ${result.stderr}`); } if (isImage) { const trimmed = result.stdout.trim(); if (trimmed.length === 0) { return { content: createBinaryDiffPlaceholder(filePath), isTruncated: false, }; } } return truncateDiff(result.stdout, maxLines, maxChars); } // Use git show with --pretty=format: to get only the diff without commit message const result = await gitExec( ['show', '--pretty=format:', commitHash, '--', filePath], repoPath, ); if (result.exitCode !== 0) { throw new Error(`Failed to get file diff: ${result.stderr}`); } return truncateDiff(result.stdout, maxLines, maxChars); } /** * Get the content of a specific file at a commit * @param maxLines - Maximum number of lines to return (default: 500) * @param maxChars - Maximum number of characters to return (default: 10000) */ export async function getFileContent( repoPath: string, commitHash: string, filePath: string, maxLines = 500, maxChars = 10000, ): Promise { if (!commitHash) { const absolutePath = path.join(repoPath, filePath); // Check if file exists in working tree try { await fs.access(absolutePath); } catch { // File doesn't exist (deleted), try to get from HEAD try { const result = await gitExec( ['show', `HEAD:${filePath}`], repoPath, ); if (result.exitCode === 0) { return truncateContent(result.stdout, maxLines, maxChars); } } catch { // Silently fail and throw main error } throw new Error(`File not found: ${filePath} (deleted)`); } try { const content = await fs.readFile(absolutePath, 'utf-8'); return truncateContent(content, maxLines, maxChars); } catch (error) { throw new Error(`Failed to read working tree file: ${error instanceof Error ? error.message : String(error)}`); } } // Use git show to get the file content at the specific commit const result = await gitExec( ['show', `${commitHash}:${filePath}`], repoPath, ); if (result.exitCode !== 0) { throw new Error(`Failed to get file content: ${result.stderr}`); } return truncateContent(result.stdout, maxLines, maxChars); } /** * Get binary file content (e.g., images) from a commit as base64 data URL * Similar to GitHub Desktop's getBlobImage implementation * Reference: https://github.com/desktop/desktop/blob/main/app/src/lib/git/show.ts */ export async function getFileBinaryContent( repoPath: string, commitHash: string, filePath: string, ): Promise { if (!commitHash) { try { const fullPath = path.join(repoPath, filePath); const buffer = await fs.readFile(fullPath); return bufferToDataUrl(buffer, filePath); } catch (error) { throw new Error(`Failed to read binary file from working tree: ${error instanceof Error ? error.message : String(error)}`); } } // Use gitExec with encoding: 'buffer' to get binary data as Buffer // This is the same approach GitHub Desktop uses in getBlobContents const result = await gitExec( ['show', `${commitHash}:${filePath}`], repoPath, { encoding: 'buffer' as const, // dugite 3.x supports this option }, ); if (result.exitCode !== 0) { const errorMessage = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf-8') : String(result.stderr); console.error('[getFileBinaryContent] Git error:', errorMessage); throw new Error(`Failed to get binary file content: ${errorMessage}`); } // When encoding is 'buffer', stdout is a Buffer (dugite 3.x) const buffer = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(String(result.stdout), 'binary'); console.log('[getFileBinaryContent] Buffer size:', buffer.length); return bufferToDataUrl(buffer, filePath); } /** * Get binary image comparison data for a file change * Returns both previous and current versions of the image * Reference: GitHub Desktop's getImageDiff implementation */ export async function getImageComparison( repoPath: string, commitHash: string, filePath: string, ): Promise<{ previous: string | null; current: string | null }> { // Get current version (at this commit) let current: string | null = null; try { current = await getFileBinaryContent(repoPath, commitHash, filePath); } catch { // File might be deleted in this commit } // Get previous version (at parent commit) let previous: string | null = null; try { if (!commitHash) { // Compare working tree (current) with HEAD try { previous = await getFileBinaryContent(repoPath, 'HEAD', filePath); } catch { // File does not exist in HEAD (newly added) } } else { // Get parent commit hash const parentResult = await gitExec( ['rev-parse', `${commitHash}^`], repoPath, ); if (parentResult.exitCode === 0) { const parentHash = parentResult.stdout.trim(); try { previous = await getFileBinaryContent(repoPath, parentHash, filePath); } catch { // File might be newly added in this commit } } } } catch { // This is the initial commit, no parent } return { previous, current }; } function bufferToDataUrl(buffer: Buffer, filePath: string): string { const base64 = buffer.toString('base64'); const extension = filePath.toLowerCase().split('.').pop(); const mimeTypes: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', }; const mimeType = mimeTypes[extension || ''] || 'application/octet-stream'; return `data:${mimeType};base64,${base64}`; } function createBinaryDiffPlaceholder(filePath: string): string { return `Binary files HEAD and working tree differ (${filePath})`; } /** * Truncate diff output if it exceeds the limits */ function truncateDiff(diff: string, maxLines: number, maxChars: number): import('./interface').IFileDiffResult { let truncated = diff; let isTruncated = false; // Check character limit first if (truncated.length > maxChars) { truncated = truncated.slice(0, maxChars); isTruncated = true; } // Check line limit const lines = truncated.split('\n'); if (lines.length > maxLines) { truncated = lines.slice(0, maxLines).join('\n'); isTruncated = true; } return { content: truncated, isTruncated, }; } /** * Truncate content if it exceeds the limits */ function truncateContent(content: string, maxLines: number, maxChars: number): import('./interface').IFileDiffResult { return truncateDiff(content, maxLines, maxChars); } /** * Checkout a specific commit */ export async function checkoutCommit(repoPath: string, commitHash: string): Promise { const result = await gitExec(['checkout', commitHash], repoPath); if (result.exitCode !== 0) { throw new Error(`Failed to checkout commit: ${result.stderr}`); } } /** * Revert a specific commit * @param commitMessage - The original commit message to include in the revert message */ export async function revertCommit(repoPath: string, commitHash: string, commitMessage?: string): Promise { const result = await gitExec(['revert', '--no-commit', commitHash], repoPath); if (result.exitCode !== 0) { throw new Error(`Failed to revert commit: ${result.stderr}`); } // Create revert commit message with the original commit message const revertMessage = commitMessage ? i18n.t('ContextMenu.RevertCommit', { message: commitMessage }) : `Revert commit ${commitHash}`; // Commit the revert with author/committer identity const commitResult = await gitExec( ['commit', '-m', revertMessage], repoPath, { env: getGitCommitEnvironment(), }, ); if (commitResult.exitCode !== 0) { throw new Error(`Failed to commit revert: ${commitResult.stderr}`); } } /** * Discard changes for a specific file (restore from HEAD or delete if untracked) */ export async function discardFileChanges(repoPath: string, filePath: string): Promise { // First check the file status to determine if it's untracked (new file) const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain', '--', filePath], repoPath); if (statusResult.exitCode !== 0) { throw new Error(`Failed to check file status: ${statusResult.stderr}`); } const statusLine = statusResult.stdout.trim(); // If empty, file is not modified if (!statusLine) { return; } // Parse status code (first two characters) const statusCode = statusLine.slice(0, 2); // Check if file is untracked (new file not yet added) // Status code "??" means untracked if (statusCode === '??') { // For untracked files, we need to delete them const fullPath = path.join(repoPath, filePath); try { await fs.unlink(fullPath); } catch (error) { throw new Error(`Failed to delete untracked file: ${error instanceof Error ? error.message : String(error)}`); } } else { // For tracked files, use git checkout to restore from HEAD const result = await gitExec(['checkout', 'HEAD', '--', filePath], repoPath); if (result.exitCode !== 0) { throw new Error(`Failed to discard changes: ${result.stderr}`); } } } /** * Add a file pattern to .gitignore */ export async function addToGitignore(repoPath: string, pattern: string): Promise { const gitignorePath = `${repoPath}/.gitignore`; try { // Read existing .gitignore or create new let content = ''; try { content = await fs.readFile(gitignorePath, 'utf-8'); } catch { // File doesn't exist, will create new } // Check if pattern already exists const lines = content.split('\n'); if (lines.some(line => line.trim() === pattern)) { return; // Pattern already exists } // Add pattern (ensure file ends with newline) const newContent = content.trim() + (content ? '\n' : '') + pattern + '\n'; await fs.writeFile(gitignorePath, newContent, 'utf-8'); } catch (error) { throw new Error(`Failed to update .gitignore: ${error instanceof Error ? error.message : String(error)}`); } } /** * Amend the last commit with a new message */ export async function amendCommitMessage(repoPath: string, newMessage: string): Promise { const result = await gitExec( ['commit', '--amend', '-m', newMessage], repoPath, { env: getGitCommitEnvironment(), }, ); if (result.exitCode !== 0) { throw new Error(`Failed to amend commit message: ${result.stderr}`); } } /** * Get deleted tiddler titles from git history since a specific date * This looks for deleted .tid and .meta files and extracts their title field * @param repoPath - Path to the git repository * @param sinceDate - Date to check for deletions after this time * @returns Array of deleted tiddler titles */ export async function getDeletedTiddlersSinceDate(repoPath: string, sinceDate: Date): Promise { try { // Format date for git log (ISO format) const sinceISOString = sinceDate.toISOString(); // Get list of deleted files since sinceDate // Using git log with --diff-filter=D to show only deletions const logResult = await gitExec( ['-c', 'core.quotePath=false', 'log', `--since=${sinceISOString}`, '--diff-filter=D', '--name-only', '--pretty=format:'], repoPath, ); if (logResult.exitCode !== 0) { throw new Error(`Failed to get deleted files: ${logResult.stderr}`); } const deletedFiles = logResult.stdout .trim() .split('\n') .filter((line: string) => line.length > 0) .filter((file: string) => file.endsWith('.tid') || file.endsWith('.meta')); if (deletedFiles.length === 0) { return []; } // For each deleted file, get its content from git history to extract the title // Parallelize git operations for efficiency (avoid serial git exec calls) const deletedTitlePromises = deletedFiles.map(async (file) => { try { // Get the last commit that had this file (before deletion) const revListResult = await gitExec( ['rev-list', '-n', '1', 'HEAD', '--', file], repoPath, ); if (revListResult.exitCode !== 0 || !revListResult.stdout.trim()) { return null; } const lastCommitHash = revListResult.stdout.trim(); // Get the file content from that commit const showResult = await gitExec( ['show', `${lastCommitHash}:${file}`], repoPath, ); if (showResult.exitCode !== 0) { return null; } return extractTitleFromTiddlerContent(showResult.stdout); } catch (error) { console.error(`Error processing deleted file ${file}:`, error); return null; } }); const deletedTitles = await Promise.all(deletedTitlePromises); // Remove nulls and duplicates return [...new Set(deletedTitles.filter((title): title is string => title !== null))]; } catch (error) { console.error('Error getting deleted tiddlers:', error); return []; } } /** * Get tiddler content at a specific point in time from git history * This is used for 3-way merge to get the base version * @param repoPath - Path to the git repository * @param tiddlerTitle - Title of the tiddler * @param beforeDate - Get the version that existed before this date * @returns Tiddler fields including text, or null if not found */ export async function getTiddlerAtTime( repoPath: string, tiddlerTitle: string, beforeDate: Date, ): Promise<{ fields: Record; text: string } | null> { try { // Find commits that modified any file before the specified date const beforeISOString = beforeDate.toISOString(); // First, find all .tid and .meta files that might contain this tiddler // We need to search for files because the title might not match the filename const logResult = await gitExec( ['-c', 'core.quotePath=false', 'log', `--before=${beforeISOString}`, '--name-only', '--pretty=format:%H', '--', '*.tid', '*.meta'], repoPath, ); if (logResult.exitCode !== 0) { return null; } const lines = logResult.stdout.trim().split('\n'); // Parse output: commit hash followed by file names let currentCommit: string | null = null; const filesToCheck: Array<{ commit: string; file: string }> = []; for (const line of lines) { if (line.length === 40 && /^[0-9a-f]+$/.test(line)) { // This is a commit hash currentCommit = line; } else if (line.trim().length > 0 && currentCommit) { // This is a file name const file = line.trim(); if (file.endsWith('.tid') || file.endsWith('.meta')) { filesToCheck.push({ commit: currentCommit, file }); } } } // Check each file to find the one with matching title // Use Promise.all to check files in parallel instead of sequentially const searchPromises = filesToCheck.map(async ({ commit, file }) => { try { const showResult = await gitExec( ['show', `${commit}:${file}`], repoPath, ); if (showResult.exitCode === 0) { const content = showResult.stdout; const parsedTiddler = parseTiddlerContent(content); if (parsedTiddler.fields.title === tiddlerTitle) { return parsedTiddler; // Match found } } } catch { // Continue checking other files } return null; // No match in this file }); // Return the first match found, or null if none match const results = await Promise.all(searchPromises); return results.find((result): result is { fields: Record; text: string } => result !== null) ?? null; } catch (error) { console.error('Error getting tiddler at time:', error); return null; } } /** * Parse tiddler content (tid or meta file) into fields and text */ function parseTiddlerContent(content: string): { fields: Record; text: string } { const lines = content.split('\n'); const fields: Record = {}; let textStartIndex = 0; // Parse headers for (let index = 0; index < lines.length; index++) { const line = lines[index]; // Empty line marks end of headers if (line.trim() === '') { textStartIndex = index + 1; break; } // Parse field: value format const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const fieldName = line.slice(0, colonIndex).trim(); const fieldValue = line.slice(colonIndex + 1).trim(); fields[fieldName] = fieldValue; } } // Get text content (everything after the empty line) const text = lines.slice(textStartIndex).join('\n'); return { fields, text }; } /** * Extract title field from tiddler content (tid or meta file) * Tiddler files have format: * ``` * title: My Tiddler Title * tags: [[Tag1]] [[Tag2]] * ... * * Tiddler text content... * ``` */ function extractTitleFromTiddlerContent(content: string): string | null { const lines = content.split('\n'); for (const line of lines) { // Look for "title:" field (case-insensitive) const titleMatch = line.match(/^title:\s*(.+)$/i); if (titleMatch) { return titleMatch[1].trim(); } // Stop at empty line (end of headers) if (line.trim() === '') { break; } } return null; }