OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / git-browser.js

466 lines · javascript ref:
import subprocess from 'bare-subprocess'
import path from 'bare-path'
import os from 'bare-os'

/**
 * Git Repository Browser
 *
 * Reads files, commits, and directory structure directly from bare git repositories
 * using native git commands.
 */
export class GitBrowser {
  constructor(options = {}) {
    this.gitReposPath = options.gitReposPath || path.join(os.homedir(), 'git-repos')
  }

  /**
   * Get the path to a bare repository.
   * Validates that the resolved path stays within gitReposPath (prevents path traversal).
   */
  getRepoPath(repoName) {
    const resolved = path.resolve(this.gitReposPath, `${repoName}.git`)
    const base = path.resolve(this.gitReposPath)
    if (!resolved.startsWith(base + path.sep) && resolved !== base) {
      throw new Error('Invalid repository name')
    }
    return resolved
  }

  /**
   * Execute git command in repository
   */
  async execGit(repoPath, args) {
    return new Promise((resolve, reject) => {
      const proc = subprocess.spawn('git', ['-C', repoPath, ...args], {
        stdio: ['ignore', 'pipe', 'pipe']
      })

      const chunks = []
      const errorChunks = []

      const timeout = setTimeout(() => {
        proc.stdout.removeAllListeners()
        proc.stderr.removeAllListeners()
        proc.kill('SIGTERM')
        reject(new Error(`Git command timed out after 60s: git ${args.join(' ')}`))
      }, 60000)

      proc.stdout.on('data', (chunk) => chunks.push(chunk))
      proc.stderr.on('data', (chunk) => errorChunks.push(chunk))

      // CRITICAL: Use 'close' instead of 'exit' to ensure all streams are finished
      // The 'exit' event can fire before stdout/stderr have been fully consumed in bare-subprocess
      proc.on('close', (code) => {
        clearTimeout(timeout)
        if (code === 0) {
          const output = Buffer.concat(chunks).toString('utf-8')
          resolve(output)
        } else {
          const error = Buffer.concat(errorChunks).toString('utf-8')
          reject(new Error(`Git command failed: ${error}`))
        }
      })

      proc.on('error', (err) => {
        clearTimeout(timeout)
        reject(err)
      })
    })
  }

  /**
   * List files in a directory at a specific commit
   */
  async listFiles(repoName, ref = 'HEAD', dirPath = '') {
    const repoPath = this.getRepoPath(repoName)
    const treePath = dirPath ? `${ref}:${dirPath}` : ref

    try {
      const output = await this.execGit(repoPath, ['ls-tree', '-l', '--', treePath])
      const lines = output.trim().split('\n').filter(l => l)

      return lines.map(line => {
        // Format: <mode> <type> <hash> <size> <name>
        // Example: 100644 blob abc123... 1234 README.md
        const parts = line.split(/\s+/)
        const mode = parts[0]
        const type = parts[1]
        const hash = parts[2]
        const sizeOrDash = parts[3]
        const name = parts.slice(4).join(' ')

        // Determine type: tree=directory, commit=submodule, blob=file
        let itemType = 'file'
        if (type === 'tree') {
          itemType = 'directory'
        } else if (type === 'commit') {
          itemType = 'submodule'
        }

        return {
          name,
          type: itemType,
          hash,
          size: sizeOrDash === '-' ? null : parseInt(sizeOrDash),
          mode,
          path: dirPath ? `${dirPath}/${name}` : name,
          isSubmodule: type === 'commit'
        }
      })
    } catch (err) {
      console.error('List files error:', err.message)
      return []
    }
  }

  /**
   * Read file contents at a specific commit
   */
  async readFile(repoName, filePath, ref = 'HEAD') {
    const repoPath = this.getRepoPath(repoName)

    try {
      const content = await this.execGit(repoPath, ['show', `${ref}:${filePath}`])
      return content
    } catch (err) {
      throw new Error(`Failed to read file: ${err.message}`)
    }
  }

  /**
   * Get commit history
   */
  async getCommits(repoName, ref = 'HEAD', limit = 20) {
    const repoPath = this.getRepoPath(repoName)

    try {
      // Use --format to get structured output
      const format = '%H%n%an%n%ae%n%at%n%s%n%b%n--END--'
      const output = await this.execGit(repoPath, [
        'log',
        `--max-count=${limit}`,
        `--format=${format}`,
        ref,
        '--'
      ])

      const commits = []
      const entries = output.split('--END--\n').filter(e => e.trim())

      for (const entry of entries) {
        const lines = entry.trim().split('\n')
        if (lines.length >= 5) {
          commits.push({
            hash: lines[0],
            author: lines[1],
            email: lines[2],
            timestamp: parseInt(lines[3]),
            subject: lines[4],
            body: lines.slice(5).join('\n').trim(),
            shortHash: lines[0].substring(0, 7),
            date: new Date(parseInt(lines[3]) * 1000).toISOString()
          })
        }
      }

      return commits
    } catch (err) {
      console.error('[GitBrowser] Get commits error:', err.message)
      return []
    }
  }

  /**
   * Get single commit details
   */
  async getCommit(repoName, hash) {
    const commits = await this.getCommits(repoName, hash, 1)
    return commits[0] || null
  }

  /**
   * Get branches
   */
  async getBranches(repoName) {
    const repoPath = this.getRepoPath(repoName)

    try {
      const output = await this.execGit(repoPath, ['branch', '-a'])
      const lines = output.trim().split('\n').filter(l => l)

      return lines.map(line => {
        const name = line.replace(/^\*?\s+/, '').trim()
        const isCurrent = line.startsWith('*')
        return { name, current: isCurrent }
      })
    } catch (err) {
      return []
    }
  }

  /**
   * Get tags
   */
  async getTags(repoName) {
    const repoPath = this.getRepoPath(repoName)

    try {
      const output = await this.execGit(repoPath, ['tag', '-l'])
      return output.trim().split('\n').filter(t => t)
    } catch (err) {
      return []
    }
  }

  /**
   * Get repository statistics
   */
  async getStats(repoName) {
    const repoPath = this.getRepoPath(repoName)

    try {
      const [commitCount, branches, tags] = await Promise.all([
        this.execGit(repoPath, ['rev-list', '--count', 'HEAD']).catch(() => '0'),
        this.getBranches(repoName),
        this.getTags(repoName)
      ])

      return {
        commits: parseInt(commitCount.trim()),
        branches: branches.length,
        tags: tags.length
      }
    } catch (err) {
      return { commits: 0, branches: 0, tags: 0 }
    }
  }

  /**
   * Search files by name
   */
  async searchFiles(repoName, query, ref = 'HEAD') {
    const repoPath = this.getRepoPath(repoName)

    try {
      const output = await this.execGit(repoPath, [
        'ls-tree',
        '-r',
        '--name-only',
        '--',
        ref
      ])

      const allFiles = output.trim().split('\n').filter(f => f)
      const results = allFiles.filter(f =>
        f.toLowerCase().includes(query.toLowerCase())
      )

      return results.slice(0, 50) // Limit results
    } catch (err) {
      return []
    }
  }

  /**
   * Get README content
   */
  async getReadme(repoName, ref = 'HEAD') {
    const files = await this.listFiles(repoName, ref, '')
    const readme = files.find(f =>
      f.name.toLowerCase() === 'readme.md' ||
      f.name.toLowerCase() === 'readme' ||
      f.name.toLowerCase() === 'readme.txt'
    )

    if (!readme) return null

    try {
      const content = await this.readFile(repoName, readme.name, ref)
      return {
        name: readme.name,
        content,
        isMarkdown: readme.name.toLowerCase().endsWith('.md')
      }
    } catch {
      return null
    }
  }

  /**
   * Get diff between commits
   */
  async getDiff(repoName, fromCommit, toCommit = 'HEAD') {
    const repoPath = this.getRepoPath(repoName)

    try {
      const output = await this.execGit(repoPath, [
        'diff',
        '--',
        fromCommit,
        toCommit
      ])
      return output
    } catch (err) {
      throw new Error(`Failed to get diff: ${err.message}`)
    }
  }

  /**
   * Check if file is binary
   */
  async isBinary(repoName, filePath, ref = 'HEAD') {
    const repoPath = this.getRepoPath(repoName)

    try {
      // Git returns exit code 0 if text, 1 if binary
      await this.execGit(repoPath, ['diff', '--no-index', '/dev/null', `${ref}:${filePath}`])
      return false
    } catch {
      return true
    }
  }

  /**
   * Get submodules from .gitmodules file
   */
  async getSubmodules(repoName, ref = 'HEAD') {
    const repoPath = this.getRepoPath(repoName)

    try {
      const gitmodulesContent = await this.execGit(repoPath, ['show', `${ref}:.gitmodules`])
      const submodules = []

      // Parse .gitmodules (INI format)
      const lines = gitmodulesContent.split('\n')
      let currentSubmodule = null

      for (const line of lines) {
        const trimmed = line.trim()

        // New submodule section
        if (trimmed.startsWith('[submodule')) {
          if (currentSubmodule) {
            submodules.push(currentSubmodule)
          }
          const match = trimmed.match(/\[submodule "(.+)"\]/)
          currentSubmodule = {
            name: match ? match[1] : '',
            path: '',
            url: ''
          }
        } else if (currentSubmodule && trimmed) {
          // Parse properties
          if (trimmed.startsWith('path =')) {
            currentSubmodule.path = trimmed.replace('path =', '').trim()
          } else if (trimmed.startsWith('url =')) {
            currentSubmodule.url = trimmed.replace('url =', '').trim()
          }
        }
      }

      if (currentSubmodule) {
        submodules.push(currentSubmodule)
      }

      return submodules
    } catch (err) {
      // No .gitmodules file
      return []
    }
  }

  /**
   * Get info for a specific submodule path
   */
  async getSubmoduleInfo(repoName, submodulePath, ref = 'HEAD') {
    const submodules = await this.getSubmodules(repoName, ref)
    return submodules.find(sub => sub.path === submodulePath) || null
  }

  /**
   * Get commit details with diff
   * Returns commit info and full diff showing what changed
   */
  async getCommitDiff(repoName, commitHash) {
    const repoPath = this.getRepoPath(repoName)

    try {
      // Get commit info with parent hash
      const format = '%H%n%P%n%an%n%ae%n%at%n%s%n%b'
      const [infoOutput, diffOutput, statOutput] = await Promise.all([
        // Get commit info
        this.execGit(repoPath, ['log', '-1', `--format=${format}`, '--', commitHash]),
        // Get full diff (--root handles initial commits with no parent)
        this.execGit(repoPath, ['diff-tree', '-p', '--root', '-M', '-C', commitHash]),
        // Get stats (files changed, insertions, deletions)
        this.execGit(repoPath, ['diff-tree', '--stat', '--root', '-M', '-C', commitHash])
      ])

      // Parse commit info
      const lines = infoOutput.trim().split('\n')
      const info = {
        hash: lines[0] || commitHash,
        shortHash: (lines[0] || commitHash).slice(0, 7),
        parents: (lines[1] || '').split(' ').filter(Boolean),
        author: lines[2] || '',
        email: lines[3] || '',
        timestamp: parseInt(lines[4], 10) * 1000 || Date.now(),
        date: new Date(parseInt(lines[4], 10) * 1000).toLocaleString(),
        subject: lines[5] || '',
        body: lines.slice(6).join('\n').trim()
      }

      // Parse stat summary from last line
      const statLines = statOutput.trim().split('\n')
      const statSummary = statLines[statLines.length - 1] || ''
      const statMatch = statSummary.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/)

      const stats = {
        filesChanged: statMatch ? parseInt(statMatch[1], 10) : 0,
        additions: statMatch && statMatch[2] ? parseInt(statMatch[2], 10) : 0,
        deletions: statMatch && statMatch[3] ? parseInt(statMatch[3], 10) : 0
      }

      return {
        info,
        diff: diffOutput,
        stats
      }
    } catch (err) {
      console.error('[GitBrowser] Get commit diff error:', err.message)
      throw new Error(`Failed to get commit diff: ${err.message}`)
    }
  }

  /**
   * Get file content at a specific commit (for showing file in commit context)
   */
  async getFileAtCommit(repoName, filePath, commitHash) {
    const repoPath = this.getRepoPath(repoName)

    try {
      const content = await this.execGit(repoPath, ['show', `${commitHash}:${filePath}`])
      return content
    } catch (err) {
      // File might not exist at this commit (new file or deleted)
      return null
    }
  }

  /**
   * Extract repo name from git URL
   */
  parseRepoNameFromURL(url) {
    // Handle various URL formats:
    // git://tabertron.local/holepunchto-kb.git → holepunchto-kb
    // https://github.com/evbogue/ANProto.git → ANProto
    // git@github.com:user/repo.git → repo

    const match = url.match(/\/([^/]+?)(?:\.git)?$/)
    if (match) {
      return match[1]
    }
    return null
  }
}