import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import { validateName, RESERVED_NAMES } from './reserved-names.js'
/**
* Repository Registry
*
* Scans and registers bare git repositories without importing to Hyperdrive.
* For HTTP git access, we read directly from bare repos.
*/
export class RepoRegistry {
constructor(options = {}) {
this.registry = options.registry // Hyperbee instance
this.gitReposPath = options.gitReposPath || path.join(os.homedir(), 'git-repos')
this.onRepoRegistered = options.onRepoRegistered || null // callback(repoName) — called on NEW registrations
}
/**
* Validate a repository name against reserved system routes.
* @param {string} name
* @returns {{ valid: boolean, error?: string }}
*/
static validateRepoName (name) {
return validateName(name)
}
/**
* Execute git command
*/
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.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))
proc.on('exit', (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(Buffer.concat(chunks).toString('utf-8'))
} else {
const error = Buffer.concat(errorChunks).toString('utf-8')
reject(new Error(`Git command failed: ${error}`))
}
})
proc.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
}
/**
* Check if directory is a bare git repo
*/
async isBareRepo(dirPath) {
try {
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
}
}
/**
* Extract hierarchy information from repo name
* Examples:
* 'hypercore' -> { namespace: null, category: null, displayName: 'hypercore' }
* 'hypercore-kb' -> { namespace: null, category: 'hypercore-kb', displayName: 'hypercore-kb' } (keep 2-part names together)
* 'hyper-wallet' -> { namespace: null, category: 'hyper-fleeky', displayName: 'hyper-wallet' } (known mapping)
* 'holepunchto-hypercore' -> { namespace: 'holepunchto', category: 'holepunchto', displayName: 'hypercore' }
* 'hyper-fleeky-repos-wirecore' -> { namespace: 'hyper-fleeky/repos', category: 'hyper-fleeky', displayName: 'wirecore' }
*/
extractHierarchy(repoName) {
// Known project mappings - repos that belong to specific categories
const knownCategories = {
'hyper-wallet': 'hyper-fleeky',
'hyper-wallet-desktop': 'hyper-fleeky',
'hyper-wallet-lib': 'hyper-fleeky',
'wirecore': 'hyper-fleeky',
'wiredove': 'hyper-fleeky',
'hyperident': 'hyper-fleeky',
'hyperident-gateway': 'hyper-fleeky',
'pear-git': 'hyper-fleeky',
'apds': 'evbogue',
'ANProto': 'evbogue',
'holepunchto-kb': 'server',
'server-info-kb': 'server'
}
// Prefixes that should consolidate all prefix-* repos into one group
const knownPrefixes = {
opencog: 'opencog'
}
// Check if this repo has a known category mapping
if (knownCategories[repoName]) {
const category = knownCategories[repoName]
return {
namespace: category,
parent: category,
category,
displayName: repoName,
hierarchyPath: [category, repoName]
}
}
// Split by hyphen
const parts = repoName.split('-')
if (parts.length === 1) {
// Simple repo name with no namespace
return {
namespace: null,
parent: null,
category: 'uncategorized',
displayName: repoName,
hierarchyPath: [repoName]
}
}
// Check if the first part is a known prefix — consolidate under that prefix
const prefix = parts[0]
if (knownPrefixes[prefix]) {
const category = knownPrefixes[prefix]
return {
namespace: category,
parent: category,
category,
displayName: repoName,
hierarchyPath: [category, repoName]
}
}
// Two-part names: treat as a single category (top-level projects)
if (parts.length === 2) {
return {
namespace: null,
parent: null,
category: repoName,
displayName: repoName,
hierarchyPath: [repoName]
}
}
// Multi-part pattern: namespace-subspace-...-repo
const displayName = parts[parts.length - 1]
const namespaceParts = parts.slice(0, -1)
const namespace = namespaceParts.join('/')
const parent = namespaceParts[0]
const category = parent
return {
namespace,
parent,
category,
displayName,
hierarchyPath: [...namespaceParts, displayName]
}
}
/**
* Get repository info from bare repo
*/
async getRepoInfo(repoPath, repoName) {
try {
// Get commit count
const commitCount = await this.execGit(repoPath, ['rev-list', '--count', 'HEAD'])
.catch(() => '0')
// Get last commit info
const lastCommit = await this.execGit(repoPath, [
'log', '-1', '--format=%H%n%an%n%ae%n%at%n%s'
]).catch(() => null)
let lastCommitInfo = null
if (lastCommit) {
const lines = lastCommit.trim().split('\n')
if (lines.length >= 5) {
lastCommitInfo = {
hash: lines[0],
author: lines[1],
email: lines[2],
timestamp: parseInt(lines[3]),
message: lines[4]
}
}
}
// Get branch count
const branches = await this.execGit(repoPath, ['branch', '-a'])
.catch(() => '')
const branchCount = branches.trim().split('\n').filter(b => b).length
// Extract hierarchy information
const hierarchy = this.extractHierarchy(repoName)
return {
name: repoName,
path: repoPath,
commits: parseInt(commitCount.trim()) || 0,
branches: branchCount,
lastCommit: lastCommitInfo,
isBare: true,
registeredAt: Date.now(),
// Hierarchy fields
namespace: hierarchy.namespace,
parent: hierarchy.parent,
category: hierarchy.category,
displayName: hierarchy.displayName,
hierarchyPath: hierarchy.hierarchyPath,
// Visibility and ownership fields
visibility: 'public', // 'public', 'private', 'internal'
owner: null, // User ID who owns this repo
collaborators: [], // [{ userId, permissions: ['read', 'write'] }]
archived: false
}
} catch (err) {
console.error(`Error getting repo info for ${repoName}:`, err.message)
return null
}
}
/**
* Scan directory for bare git repositories
*/
async scan(options = {}) {
const { paths = [this.gitReposPath], maxDepth = 1, detectSubmodules = false } = options
const repos = []
for (const scanPath of paths) {
try {
const entries = await fs.promises.readdir(scanPath)
for (const entry of entries) {
if (!entry.endsWith('.git')) continue
const fullPath = path.join(scanPath, entry)
const stat = await fs.promises.stat(fullPath).catch(() => null)
if (!stat?.isDirectory()) continue
const isBare = await this.isBareRepo(fullPath)
if (!isBare) continue
// Skip repos with .pear-git-ignore marker
const ignoreMarker = path.join(fullPath, '.pear-git-ignore')
const ignored = await fs.promises.stat(ignoreMarker).catch(() => null)
if (ignored) continue
const repoName = entry.replace(/\.git$/, '')
// Auto-install post-receive hook if missing
await this.ensurePostReceiveHook(fullPath)
await this.ensurePartialCloneConfig(fullPath)
const repoInfo = await this.getRepoInfo(fullPath, repoName)
if (repoInfo) {
// Optionally detect submodules
if (detectSubmodules) {
const submodules = await this.getSubmodules(fullPath)
if (submodules.length > 0) {
repoInfo.submodules = submodules
console.log(` Found: ${repoName} (${repoInfo.commits} commits, ${submodules.length} submodules)`)
} else {
console.log(` Found: ${repoName} (${repoInfo.commits} commits)`)
}
} else {
console.log(` Found: ${repoName} (${repoInfo.commits} commits)`)
}
repos.push(repoInfo)
}
}
} catch (err) {
console.error(`Error scanning ${scanPath}:`, err.message)
}
}
// Post-process: detect submodule relationships
if (detectSubmodules) {
await this.linkSubmoduleRelationships(repos)
}
return repos
}
/**
* Ensure a bare repo has a post-receive hook that notifies pear-git.
* Without this hook, pushes to the bare repo won't trigger federation
* sync notifications and peers won't learn about new refs.
*/
/**
* Enable partial clone support on a bare repo.
* Required for --filter=blob:none to work during fetch/clone.
* Without this, git ignores filter requests and sends full packs.
*/
async ensurePartialCloneConfig (repoPath) {
try {
const current = await this.execGit(repoPath, ['config', '--get', 'uploadpack.allowFilter']).catch(() => '')
if (current.trim() === 'true') return
await this.execGit(repoPath, ['config', 'uploadpack.allowFilter', 'true'])
await this.execGit(repoPath, ['config', 'uploadpack.allowAnySHA1InWant', 'true'])
console.log(` ✓ Enabled partial clone: ${path.basename(repoPath, '.git')}`)
} catch (err) {
console.error(` ✗ Failed to enable partial clone for ${path.basename(repoPath)}: ${err.message}`)
}
}
async ensurePostReceiveHook (repoPath) {
const hookPath = path.join(repoPath, 'hooks', 'post-receive')
try {
await fs.promises.stat(hookPath)
return // hook already exists
} catch {}
const hookScript = `#!/bin/sh
#
# post-receive hook — triggers auto-deploy via pear-git server API
# Auto-installed by pear-git repo scan.
# Reads server URL and token from ~/.config/pear-git/pgh.json
#
REPO_NAME="$(basename "$(pwd)" .git)"
CONFIG="$HOME/.config/pear-git/pgh.json"
if [ ! -f "$CONFIG" ]; then
echo "[post-receive] No pgh config at $CONFIG — skipping auto-deploy"
exit 0
fi
SERVER="$(grep '"server"' "$CONFIG" | head -1 | sed 's/.*"server"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/')"
TOKEN="$(grep '"token"' "$CONFIG" | head -1 | sed 's/.*"token"[[:space:]]*:[[:space:]]*"\\(.*\\)".*/\\1/')"
if [ -z "$SERVER" ] || [ -z "$TOKEN" ]; then
echo "[post-receive] Missing server/token in pgh config — skipping auto-deploy"
exit 0
fi
# Fire-and-forget — don't block the push
curl -sf -X POST \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $TOKEN" \\
-d '{}' \\
"$SERVER/api/deploy/trigger/$REPO_NAME" \\
>/dev/null 2>&1 &
echo "[post-receive] Auto-deploy triggered for $REPO_NAME"
`
try {
await fs.promises.mkdir(path.join(repoPath, 'hooks'), { recursive: true })
await fs.promises.writeFile(hookPath, hookScript, { mode: 0o755 })
console.log(` ✓ Installed post-receive hook: ${path.basename(repoPath, '.git')}`)
} catch (err) {
console.error(` ✗ Failed to install hook for ${path.basename(repoPath)}: ${err.message}`)
}
}
/**
* Link parent-child relationships for submodules
* Marks repos that are submodules of other repos
*/
async linkSubmoduleRelationships(repos) {
const repoMap = new Map()
// Build map of repo names
for (const repo of repos) {
repoMap.set(repo.name, repo)
}
// Check each repo's submodules
for (const repo of repos) {
if (!repo.submodules || repo.submodules.length === 0) continue
for (const submodule of repo.submodules) {
// Extract repo name from submodule URL
const submoduleRepoName = this.parseRepoNameFromURL(submodule.url)
if (!submoduleRepoName) continue
// Check if we have this submodule repo
const submoduleRepo = repoMap.get(submoduleRepoName)
if (submoduleRepo) {
// Mark as submodule and record parent
if (!submoduleRepo.isSubmoduleOf) {
submoduleRepo.isSubmoduleOf = []
}
submoduleRepo.isSubmoduleOf.push({
parentRepo: repo.name,
path: submodule.path
})
console.log(` ↳ Linked: ${submoduleRepoName} is submodule of ${repo.name}`)
}
}
}
}
/**
* Parse .gitmodules file to detect submodules
* Returns array of { name, path, url } objects
*/
async getSubmodules(repoPath) {
try {
const gitmodulesContent = await this.execGit(repoPath, ['show', 'HEAD:.gitmodules'])
.catch(() => null)
if (!gitmodulesContent) {
return []
}
const submodules = []
const lines = gitmodulesContent.split('\n')
let currentSubmodule = null
for (const line of lines) {
const trimmed = line.trim()
// [submodule "name"]
const submoduleMatch = trimmed.match(/^\[submodule\s+"([^"]+)"\]/)
if (submoduleMatch) {
if (currentSubmodule && currentSubmodule.path && currentSubmodule.url) {
submodules.push(currentSubmodule)
}
currentSubmodule = { name: submoduleMatch[1] }
continue
}
if (currentSubmodule) {
// path = dir/subdir
const pathMatch = trimmed.match(/^path\s*=\s*(.+)/)
if (pathMatch) {
currentSubmodule.path = pathMatch[1].trim()
continue
}
// url = git://...
const urlMatch = trimmed.match(/^url\s*=\s*(.+)/)
if (urlMatch) {
currentSubmodule.url = urlMatch[1].trim()
}
}
}
// Push last submodule
if (currentSubmodule && currentSubmodule.path && currentSubmodule.url) {
submodules.push(currentSubmodule)
}
return submodules
} catch (err) {
return []
}
}
/**
* Extract repo name from git URL
* Examples:
* 'git://tabertron.local/wirecore.git' -> 'wirecore'
* 'https://github.com/holepunchto/hypercore.git' -> 'hypercore'
*/
parseRepoNameFromURL(url) {
const match = url.match(/\/([^/]+?)(\.git)?$/)
if (match) {
return match[1]
}
return null
}
/**
* Register a repository in Hyperbee
*/
async register(repoInfo) {
// Warn if repo name collides with a system route
if (RESERVED_NAMES.has(repoInfo.name)) {
console.warn(` ⚠ Repo "${repoInfo.name}" conflicts with a system route — it may be inaccessible via the web UI`)
}
// Merge with existing entry to preserve user-configured fields
const existing = await this.registry.get(`repo:${repoInfo.name}`).catch(() => null)
if (existing && existing.value) {
const prev = existing.value
// Preserve fields that are set via separate APIs (not from scan).
// These always overwrite incoming values since scan sets defaults that
// should be replaced by user-configured values from the registry.
if (prev.storage) repoInfo.storage = prev.storage
if (prev.visibility) repoInfo.visibility = prev.visibility
if (prev.visibilityUpdatedAt) repoInfo.visibilityUpdatedAt = prev.visibilityUpdatedAt
if (prev.owner) repoInfo.owner = prev.owner
if (prev.ownerUpdatedAt) repoInfo.ownerUpdatedAt = prev.ownerUpdatedAt
if (prev.collaborators) repoInfo.collaborators = prev.collaborators
if (prev.archived != null) repoInfo.archived = prev.archived
if (prev.archivedAt) repoInfo.archivedAt = prev.archivedAt
// Deploy: only fill from prev if missing — deploy-hook calls register()
// with updated deploy fields that must not be overwritten by stale prev
if (prev.deploy && !repoInfo.deploy) repoInfo.deploy = prev.deploy
// Linked: preserve Autobase overlay metadata
if (prev.linked && !repoInfo.linked) repoInfo.linked = prev.linked
// Namespace: preserve user-assigned namespace (not from extractHierarchy)
if (prev.groupNamespace) repoInfo.groupNamespace = prev.groupNamespace
}
// Store main repo entry
await this.registry.put(`repo:${repoInfo.name}`, repoInfo)
// Store category index (for grouping in UI)
if (repoInfo.category) {
const categoryKey = `category:${repoInfo.category}`
const existingCategory = await this.registry.get(categoryKey).catch(() => null)
const repos = existingCategory ? existingCategory.value.repos : []
if (!repos.includes(repoInfo.name)) {
repos.push(repoInfo.name)
await this.registry.put(categoryKey, {
category: repoInfo.category,
repos,
updatedAt: Date.now()
})
}
}
console.log(` ✓ Registered ${repoInfo.name}${repoInfo.namespace ? ` (${repoInfo.namespace})` : ''}`)
// Notify listener — announceRepo is idempotent so safe for re-registrations
if (this.onRepoRegistered) {
try {
await this.onRepoRegistered(repoInfo.name)
} catch (err) {
console.error(` ⚠ onRepoRegistered callback failed for ${repoInfo.name}: ${err.message}`)
}
}
}
/**
* Unregister a repository (remove from registry)
*/
async unregister(repoName) {
const existing = await this.registry.get(`repo:${repoName}`).catch(() => null)
if (!existing || !existing.value) {
throw new Error(`Repository "${repoName}" not found in registry`)
}
const repoInfo = existing.value
// Remove from category index
if (repoInfo.category) {
const categoryKey = `category:${repoInfo.category}`
const existingCategory = await this.registry.get(categoryKey).catch(() => null)
if (existingCategory && existingCategory.value) {
const repos = existingCategory.value.repos.filter(r => r !== repoName)
if (repos.length > 0) {
await this.registry.put(categoryKey, {
category: repoInfo.category,
repos,
updatedAt: Date.now()
})
} else {
await this.registry.del(categoryKey)
}
}
}
// Remove main repo entry
await this.registry.del(`repo:${repoName}`)
console.log(` ✗ Unregistered ${repoName}`)
return repoInfo
}
/**
* Register all repositories
*/
async registerAll(repos) {
for (const repo of repos) {
await this.register(repo)
}
}
/**
* List all registered repositories
* Filters out repos whose bare directory contains a .pear-git-ignore marker
*/
async listAll() {
const repos = []
try {
for await (const entry of this.registry.createReadStream({
gt: 'repo:',
lt: 'repo:\xff'
})) {
// Skip repos with .pear-git-ignore marker on disk
if (entry.value && entry.value.path) {
const ignoreMarker = path.join(entry.value.path, '.pear-git-ignore')
const ignored = await fs.promises.stat(ignoreMarker).catch(() => null)
if (ignored) continue
}
repos.push(entry.value)
}
} catch (err) {
console.error('Error listing repos:', err)
}
return repos
}
/**
* List all categories with repo counts
*/
async listCategories() {
const categories = []
try {
for await (const entry of this.registry.createReadStream({
gt: 'category:',
lt: 'category:\xff'
})) {
categories.push({
name: entry.value.category,
repoCount: entry.value.repos.length,
repos: entry.value.repos
})
}
} catch (err) {
console.error('Error listing categories:', err)
}
return categories
}
/**
* List repositories grouped by category
*/
async listByCategory() {
const allRepos = await this.listAll()
const grouped = new Map()
// Group repos by category
for (const repo of allRepos) {
const category = repo.category || 'uncategorized'
if (!grouped.has(category)) {
grouped.set(category, [])
}
grouped.get(category).push(repo)
}
// Convert to array and sort by category name
const result = []
for (const [category, repos] of grouped.entries()) {
result.push({
category,
repos: repos.sort((a, b) => a.displayName.localeCompare(b.displayName))
})
}
return result.sort((a, b) => a.category.localeCompare(b.category))
}
/**
* Get single repository
* Returns null for repos with .pear-git-ignore marker
*/
async get(repoName) {
try {
const entry = await this.registry.get(`repo:${repoName}`)
if (!entry) return null
// Check ignore marker
if (entry.value && entry.value.path) {
const ignored = await fs.promises.stat(path.join(entry.value.path, '.pear-git-ignore')).catch(() => null)
if (ignored) return null
}
return entry.value
} catch {
return null
}
}
/**
* Alias for get() - used by git-p2p-handler
*/
async getRepo(repoName) {
return this.get(repoName)
}
/**
* Re-compute metadata (commits, branches, lastCommit) for a single repo
* and persist the updated entry. Safe to call after push — register()
* merges with existing user-configured fields.
* @param {string} repoName
* @returns {Object|null} Updated repo info, or null if repo not found
*/
async refreshRepoMetadata (repoName) {
const existing = await this.get(repoName)
if (!existing || !existing.path) return null
const updated = await this.getRepoInfo(existing.path, repoName)
if (!updated) return null
await this.register(updated)
return updated
}
/**
* Check if repository exists in registry
*/
async hasRepo(repoName) {
const repo = await this.get(repoName)
return repo !== null
}
/**
* Delete a repository from the registry
* @param {string} repoName - Repository name
* @param {Object} options - { deleteBareRepo: false }
* @returns {Object} { name, deleted, bareDeleted }
*/
async deleteRepo(repoName, options = {}) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
// Remove from category index
if (repoData.category) {
const categoryKey = `category:${repoData.category}`
const existing = await this.registry.get(categoryKey).catch(() => null)
if (existing && existing.value && existing.value.repos) {
const repos = existing.value.repos.filter(r => r !== repoName)
if (repos.length > 0) {
await this.registry.put(categoryKey, { ...existing.value, repos, updatedAt: Date.now() })
} else {
await this.registry.del(categoryKey)
}
}
}
// Remove from namespace if assigned
if (repoData.groupNamespace) {
const nsKey = `namespace:${repoData.groupNamespace}:repo:${repoName}`
await this.registry.del(nsKey).catch(() => {})
}
// Delete the registry entry
await this.registry.del(`repo:${repoName}`)
let bareDeleted = false
// Optionally delete the bare repo on disk
if (options.deleteBareRepo && repoData.path) {
try {
const { rmSync } = await import('fs')
rmSync(repoData.path, { recursive: true, force: true })
bareDeleted = true
console.log(` ✓ Deleted bare repo: ${repoData.path}`)
} catch (err) {
console.warn(` ⚠ Failed to delete bare repo: ${err.message}`)
}
}
console.log(` ✓ Deleted repo: ${repoName}`)
return { name: repoName, deleted: true, bareDeleted }
}
/**
* Set storage strategy for a repository
*/
async setStorageStrategy(repoName, strategy, options = {}) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
repoData.storage = {
strategy, // 'direct-git', 'git-native-hyperdrive', 'git-rpc', 'hybrid'
options,
configuredAt: Date.now()
}
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Set ${repoName} storage strategy: ${strategy}`)
}
/**
* Get storage strategy for a repository
*/
async getStorageStrategy(repoName) {
const repoData = await this.get(repoName)
return repoData && repoData.storage ? repoData.storage.strategy : null
}
/**
* Scan an arbitrary directory for git repositories (bare and regular).
* Does NOT register — returns discovered repo info for UI display.
* @param {string} dirPath - Directory to scan
* @param {number} maxDepth - Max recursion depth (default 2)
* @returns {Array<Object>} Array of { name, path, commits, branches, isBare }
*/
async scanPath (dirPath, maxDepth = 2) {
const results = []
const scanDir = async (dir, depth) => {
if (depth > maxDepth) return
let entries
try {
entries = await fs.promises.readdir(dir)
} catch {
return
}
for (const entry of entries) {
const fullPath = path.join(dir, entry)
let stat
try {
stat = await fs.promises.stat(fullPath)
} catch {
continue
}
if (!stat.isDirectory()) continue
// Check if this is a bare repo (*.git directory with HEAD/refs/objects)
if (entry.endsWith('.git')) {
const isBare = await this.isBareRepo(fullPath)
if (isBare) {
// Skip repos with .pear-git-ignore marker
const ignoreMarker = path.join(fullPath, '.pear-git-ignore')
const markerExists = await fs.promises.stat(ignoreMarker).catch(() => null)
if (markerExists) continue
const repoName = entry.replace(/\.git$/, '')
const repoInfo = await this.getRepoInfo(fullPath, repoName)
if (repoInfo) {
results.push(repoInfo)
}
continue
}
}
// Check if this is a regular git repo (has .git subdir)
const dotGitPath = path.join(fullPath, '.git')
let dotGitStat
try {
dotGitStat = await fs.promises.stat(dotGitPath)
} catch {
dotGitStat = null
}
if (dotGitStat && (dotGitStat.isDirectory() || dotGitStat.isFile())) {
// Regular git repo or submodule (.git file = gitlink to parent modules)
const repoName = entry
try {
const repoInfo = await this.getRepoInfo(fullPath, repoName)
if (repoInfo) {
repoInfo.isBare = false
results.push(repoInfo)
}
} catch {
// Skip repos we can't read
}
continue
}
// Not a git repo — recurse deeper
if (depth < maxDepth) {
await scanDir(fullPath, depth + 1)
}
}
}
await scanDir(dirPath, 0)
return results
}
/**
* Scan and register all repositories (fast)
*/
async scanAndRegister(options = {}) {
console.log('🔍 Fast-scanning git repositories...')
const repos = await this.scan(options)
console.log(`\n📦 Found ${repos.length} repositories`)
console.log('\n📝 Registering...')
await this.registerAll(repos)
console.log(`\n✅ Done! ${repos.length} repositories registered`)
return repos
}
/**
* Set repository visibility
*/
async setVisibility(repoName, visibility) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
if (!['public', 'private', 'internal'].includes(visibility)) {
throw new Error(`Invalid visibility: ${visibility}`)
}
repoData.visibility = visibility
repoData.visibilityUpdatedAt = Date.now()
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Set ${repoName} visibility: ${visibility}`)
}
/**
* Set repository owner
*/
async setOwner(repoName, userId) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
repoData.owner = userId
repoData.ownerUpdatedAt = Date.now()
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Set ${repoName} owner: ${userId}`)
}
/**
* Add collaborator to repository
*/
async addCollaborator(repoName, userId, permissions = ['read']) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
// Check if collaborator already exists
const existingIndex = repoData.collaborators.findIndex(c => c.userId === userId)
if (existingIndex >= 0) {
// Update existing collaborator permissions
repoData.collaborators[existingIndex].permissions = permissions
repoData.collaborators[existingIndex].updatedAt = Date.now()
} else {
// Add new collaborator
repoData.collaborators.push({
userId,
permissions,
addedAt: Date.now()
})
}
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Added collaborator ${userId} to ${repoName} with permissions: ${permissions.join(', ')}`)
}
/**
* Remove collaborator from repository
*/
async removeCollaborator(repoName, userId) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
repoData.collaborators = repoData.collaborators.filter(c => c.userId !== userId)
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Removed collaborator ${userId} from ${repoName}`)
}
/**
* Get repository collaborators
*/
async getCollaborators(repoName) {
const repoData = await this.get(repoName)
return repoData ? repoData.collaborators : []
}
/**
* Set repository archived status
*/
async setArchived(repoName, archived) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
repoData.archived = archived
repoData.archivedAt = archived ? Date.now() : null
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Set ${repoName} archived: ${archived}`)
}
/**
* Set linked repo metadata (Autobase overlay)
* Does NOT change storage strategy — linking is an overlay.
* @param {string} repoName
* @param {Object} linkedInfo - { bootstrapKey, role, linkedAt }
*/
async setLinked (repoName, linkedInfo) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
repoData.linked = {
bootstrapKey: linkedInfo.bootstrapKey,
role: linkedInfo.role || 'owner',
linkedAt: linkedInfo.linkedAt || Date.now()
}
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Set ${repoName} linked: ${linkedInfo.bootstrapKey.slice(0, 16)}...`)
}
/**
* Get linked repo metadata
* @returns {Object|null} { bootstrapKey, role, linkedAt } or null
*/
async getLinked (repoName) {
const repoData = await this.get(repoName)
return repoData && repoData.linked ? repoData.linked : null
}
/**
* Check if a repo is linked
*/
async isLinked (repoName) {
const linked = await this.getLinked(repoName)
return linked !== null
}
/**
* Set the namespace (group) for a repository.
* This is the authoritative group assignment, separate from extractHierarchy().
* @param {string} repoName
* @param {string|null} namespaceName
*/
async setNamespace (repoName, namespaceName) {
const repoData = await this.get(repoName)
if (!repoData) {
// Repo may not be registered yet — silently skip
return
}
repoData.groupNamespace = namespaceName
repoData.groupNamespaceUpdatedAt = Date.now()
await this.registry.put(`repo:${repoName}`, repoData)
}
/**
* Migrate all repos from current gitReposPath to a new path.
* Handles same-filesystem (rename) and cross-filesystem (git clone --bare + verify + delete).
* @param {string} newPath - New directory for bare git repos
* @returns {{ moved: number, skipped: number, errors: string[] }}
*/
async migrateReposPath (newPath) {
const oldPath = this.gitReposPath
const results = { moved: 0, skipped: 0, errors: [] }
// Ensure new path exists
try {
await fs.promises.mkdir(newPath, { recursive: true })
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
// Get all registered repos
const repos = await this.listAll()
for (const repo of repos) {
// Only migrate repos that live under the current gitReposPath
if (!repo.path || !repo.path.startsWith(oldPath)) {
results.skipped++
continue
}
const dirName = path.basename(repo.path) // e.g., "pear-git.git"
const newRepoPath = path.join(newPath, dirName)
try {
// Try same-filesystem rename first (atomic and fast)
try {
await fs.promises.rename(repo.path, newRepoPath)
} catch (renameErr) {
if (renameErr.code !== 'EXDEV') throw renameErr
// Cross-filesystem: use git clone --bare + verify + delete
await this.execGit(repo.path, ['clone', '--bare', repo.path, newRepoPath])
// Verify the clone has the same HEAD
const oldHead = await this.execGit(repo.path, ['rev-parse', 'HEAD']).catch(() => null)
const newHead = await this.execGit(newRepoPath, ['rev-parse', 'HEAD']).catch(() => null)
if (oldHead && newHead && oldHead.trim() === newHead.trim()) {
await fs.promises.rm(repo.path, { recursive: true })
} else {
throw new Error('Cross-filesystem clone verification failed')
}
}
// Update registry
repo.path = newRepoPath
await this.registry.put(`repo:${repo.name}`, repo)
results.moved++
} catch (err) {
results.errors.push(`${repo.name}: ${err.message}`)
}
}
// Update gitReposPath
this.gitReposPath = newPath
await this.registry.put('config:gitReposPath', { path: newPath })
return results
}
/**
* Remove linked metadata (unlink repo)
*/
async unlink (repoName) {
const repoData = await this.get(repoName)
if (!repoData) {
throw new Error(`Repository not found: ${repoName}`)
}
delete repoData.linked
await this.registry.put(`repo:${repoName}`, repoData)
console.log(` ✓ Unlinked ${repoName}`)
}
}