OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / repo-scanner.js

371 lines · javascript ref:
#!/usr/bin/env node

import fs from 'bare-fs'
import path from 'bare-path'
import { spawn } from 'bare-subprocess'
import Hyperbee from 'hyperbee'
import Hyperdrive from 'hyperdrive'
import Hypercore from 'hypercore'

/**
 * Scan filesystem for git repositories and import into pear-git
 *
 * Features:
 * - Recursive directory scanning
 * - Git repo detection
 * - Import git objects → Hyperdrive
 * - Track imported repos in registry
 * - Skip excluded directories
 */
export class RepoScanner {
  constructor(options = {}) {
    this.registry = options.registry  // Hyperbee registry
    this.storage = options.storage || './pear-git-storage'
    this.store = options.store  // Corestore instance

    // Directories to exclude from scanning
    this.excludeDirs = new Set([
      'node_modules',
      '.npm',
      '.cache',
      '.pear',
      'Library',
      'Applications',
      '.Trash',
      '.git',  // Don't scan inside .git dirs
      ...( options.exclude || [])
    ])

    // Paths to scan
    this.scanPaths = options.scanPaths || [
      process.env.HOME,
      '/storage/projects',
      '/home'
    ]

    this.stats = {
      scanned: 0,
      found: 0,
      imported: 0,
      errors: 0
    }
  }

  /**
   * Check if directory is a git repository (regular)
   */
  async isGitRepo(dirPath) {
    try {
      const gitDir = path.join(dirPath, '.git')
      const stat = await fs.promises.stat(gitDir)
      return stat.isDirectory()
    } catch {
      return false
    }
  }

  /**
   * Check if directory is a bare git repository
   */
  async isBareRepo(dirPath) {
    try {
      // Bare repos have HEAD, refs/, and objects/ at root level
      const headPath = path.join(dirPath, 'HEAD')
      const refsPath = path.join(dirPath, 'refs')
      const objectsPath = path.join(dirPath, 'objects')

      const [headStat, refsStat, objectsStat] = await Promise.all([
        fs.promises.stat(headPath).catch(() => null),
        fs.promises.stat(refsPath).catch(() => null),
        fs.promises.stat(objectsPath).catch(() => null)
      ])

      return headStat && refsStat?.isDirectory() && objectsStat?.isDirectory()
    } catch {
      return false
    }
  }

  /**
   * Get git repo info (works for both regular and bare repos)
   */
  async getRepoInfo(dirPath, isBare = false) {
    try {
      // Get remote URL
      const remoteUrl = await this.execGit(dirPath, ['config', '--get', 'remote.origin.url']).catch(() => '')

      // Get current branch
      const branch = await this.execGit(dirPath, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => 'master')

      // Get last commit
      const lastCommit = await this.execGit(dirPath, ['log', '-1', '--format=%H|%an|%ae|%s|%ct']).catch(() => '')
      const [hash, author, email, message, timestamp] = lastCommit.split('|')

      // Count commits
      const commitCount = await this.execGit(dirPath, ['rev-list', '--count', 'HEAD']).catch(() => '0')

      // Get repo name (strip .git suffix for bare repos)
      let repoName = path.basename(dirPath)
      if (isBare && repoName.endsWith('.git')) {
        repoName = repoName.slice(0, -4)
      }

      return {
        path: dirPath,
        name: repoName,
        remoteUrl: remoteUrl.trim() || null,
        branch: branch.trim(),
        commits: parseInt(commitCount.trim()),
        isBare,
        lastCommit: {
          hash: hash?.trim(),
          author: author?.trim(),
          email: email?.trim(),
          message: message?.trim(),
          timestamp: parseInt(timestamp?.trim() || '0')
        }
      }
    } catch (err) {
      console.error(`Error getting repo info for ${dirPath}:`, err.message)
      return null
    }
  }

  /**
   * Execute git command in repo directory
   */
  async execGit(cwd, args) {
    return new Promise((resolve, reject) => {
      const git = spawn('git', args, { cwd })
      let stdout = ''
      let stderr = ''

      git.stdout.on('data', chunk => { stdout += chunk.toString() })
      git.stderr.on('data', chunk => { stderr += chunk.toString() })

      git.on('exit', code => {
        if (code !== 0) {
          reject(new Error(stderr || `git exited with code ${code}`))
        } else {
          resolve(stdout)
        }
      })
    })
  }

  /**
   * Recursively scan directory for git repos
   */
  async *scanDirectory(dirPath, depth = 0, maxDepth = 10) {
    if (depth > maxDepth) return

    try {
      const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })

      for (const entry of entries) {
        // Skip excluded directories
        if (this.excludeDirs.has(entry.name)) continue

        const fullPath = path.join(dirPath, entry.name)

        if (entry.isDirectory()) {
          this.stats.scanned++

          // Check if this is a regular git repo
          if (await this.isGitRepo(fullPath)) {
            this.stats.found++
            const info = await this.getRepoInfo(fullPath, false)
            if (info) {
              yield info
            }
            // Don't recurse into git repos
            continue
          }

          // Check if this is a bare git repo
          if (await this.isBareRepo(fullPath)) {
            this.stats.found++
            const info = await this.getRepoInfo(fullPath, true)
            if (info) {
              yield info
            }
            // Don't recurse into bare repos
            continue
          }

          // Recurse into subdirectories
          yield* this.scanDirectory(fullPath, depth + 1, maxDepth)
        }
      }
    } catch (err) {
      // Permission denied, etc - skip
      if (err.code !== 'EACCES' && err.code !== 'EPERM') {
        console.error(`Error scanning ${dirPath}:`, err.message)
      }
    }
  }

  /**
   * Scan all configured paths
   */
  async *scan(options = {}) {
    const paths = options.paths || this.scanPaths

    console.log('🔍 Scanning filesystem for git repositories...')
    console.log(`   Paths: ${paths.join(', ')}`)
    console.log('')

    for (const scanPath of paths) {
      console.log(`   Scanning ${scanPath}...`)
      yield* this.scanDirectory(scanPath, 0, options.maxDepth || 10)
    }

    console.log('')
    console.log(`✓ Scan complete:`)
    console.log(`  Directories scanned: ${this.stats.scanned}`)
    console.log(`  Git repos found: ${this.stats.found}`)
    console.log(`  Repos imported: ${this.stats.imported}`)
    console.log(`  Errors: ${this.stats.errors}`)
  }

  /**
   * Import git repo into Hyperdrive
   */
  async importRepo(repoInfo) {
    try {
      console.log(`   Importing ${repoInfo.name}...`)

      // Create Hyperdrive for this repo using corestore
      const drive = new Hyperdrive(this.store, { name: `repo-${repoInfo.name}` })
      await drive.ready()

      // Import git objects
      await this.importGitObjects(repoInfo.path, drive, repoInfo.isBare)

      // Import git refs
      await this.importGitRefs(repoInfo.path, drive, repoInfo.isBare)

      // Register repo in Hyperbee
      await this.registry.put(`repo:${repoInfo.name}`, {
        ...repoInfo,
        imported: Date.now(),
        driveKey: drive.key.toString('hex'),
        discoveryKey: drive.discoveryKey.toString('hex')
      })

      this.stats.imported++

      console.log(`   ✓ Imported ${repoInfo.name} (${repoInfo.commits} commits)`)

      return {
        name: repoInfo.name,
        driveKey: drive.key.toString('hex'),
        url: `git+pear://${drive.key.toString('hex')}/${repoInfo.name}`
      }
    } catch (err) {
      console.error(`   ✗ Error importing ${repoInfo.name}:`, err.message)
      this.stats.errors++
      return null
    }
  }

  /**
   * Import git objects into Hyperdrive
   */
  async importGitObjects(repoPath, drive, isBare = false) {
    // For bare repos, objects are at root; for regular repos, in .git/
    const objectsPath = isBare
      ? path.join(repoPath, 'objects')
      : path.join(repoPath, '.git', 'objects')

    // Import pack files
    const packsPath = path.join(objectsPath, 'pack')
    if (fs.existsSync(packsPath)) {
      const packs = await fs.promises.readdir(packsPath)
      for (const pack of packs) {
        const packPath = path.join(packsPath, pack)
        const content = await fs.promises.readFile(packPath)
        await drive.put(`/objects/pack/${pack}`, content)
      }
    }

    // Import loose objects
    const dirs = await fs.promises.readdir(objectsPath)
    for (const dir of dirs) {
      if (dir === 'pack' || dir === 'info') continue
      if (dir.length !== 2) continue  // Git object dirs are 2 hex chars

      const dirPath = path.join(objectsPath, dir)
      const stat = await fs.promises.stat(dirPath)
      if (!stat.isDirectory()) continue

      const objects = await fs.promises.readdir(dirPath)
      for (const obj of objects) {
        const objPath = path.join(dirPath, obj)
        const content = await fs.promises.readFile(objPath)
        await drive.put(`/objects/${dir}/${obj}`, content)
      }
    }
  }

  /**
   * Import git refs into Hyperdrive
   */
  async importGitRefs(repoPath, drive, isBare = false) {
    // For bare repos, refs are at root; for regular repos, in .git/
    const refsPath = isBare
      ? path.join(repoPath, 'refs')
      : path.join(repoPath, '.git', 'refs')

    const importRefsRecursive = async (basePath, prefix = '') => {
      const entries = await fs.promises.readdir(basePath, { withFileTypes: true })

      for (const entry of entries) {
        const fullPath = path.join(basePath, entry.name)
        const refName = prefix ? `${prefix}/${entry.name}` : entry.name

        if (entry.isDirectory()) {
          await importRefsRecursive(fullPath, refName)
        } else {
          const content = await fs.promises.readFile(fullPath, 'utf-8')
          await drive.put(`/refs/${refName}`, content.trim())
        }
      }
    }

    await importRefsRecursive(refsPath)

    // Import HEAD
    const headPath = isBare
      ? path.join(repoPath, 'HEAD')
      : path.join(repoPath, '.git', 'HEAD')
    if (fs.existsSync(headPath)) {
      const head = await fs.promises.readFile(headPath, 'utf-8')
      await drive.put('/HEAD', head.trim())
    }
  }

  /**
   * List all imported repos
   */
  async *listImported() {
    for await (const entry of this.registry.createReadStream({
      gt: 'repo:',
      lt: 'repo:\xff'
    })) {
      yield entry.value
    }
  }

  /**
   * Get repo by name
   */
  async getRepo(name) {
    const entry = await this.registry.get(`repo:${name}`)
    return entry?.value || null
  }
}

// CLI Usage - Disabled (must use pear-git-server.js as entry point)
// This module is designed to be imported, not run directly