OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / repo-registry.js

1164 lines · javascript ref:
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}`)
  }
}