#!/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