/**
* Git Protocol Routes
*
* Handles Git Smart HTTP protocol:
* - GET /:username/:repoName.git/info/refs - Git info/refs (clone/fetch)
* - POST /:username/:repoName.git/git-upload-pack - Git upload-pack (clone/fetch)
* - POST /:username/:repoName.git/git-receive-pack - Git receive-pack (push)
*/
import { text, unauthorized, forbidden, notFound } from '../response.js'
import { autoDeployAfterPush } from '../ci/deploy-hook.js'
/**
* Register git protocol routes
*
* NOTE: These patterns use regex matching in the router.
* The .git extension is part of the pattern.
*/
export function registerGitRoutes(router) {
// Git info/refs (clone/fetch discovery)
router.get('/:username/:repoName.git/info/refs', handleGitInfoRefs)
// Git upload-pack (clone/fetch data transfer)
router.post('/:username/:repoName.git/git-upload-pack', handleGitUploadPack)
// Git receive-pack (push data transfer)
router.post('/:username/:repoName.git/git-receive-pack', handleGitReceivePack)
}
/**
* Handle git info/refs (clone/fetch discovery)
* Requires READ permission for private repos
*/
async function handleGitInfoRefs(req, res, server) {
const { repoRegistry, gitProtocol } = server.repoData
const { authManager, authEnabled } = server.auth
const { username, repoName } = req.params
// Strip .git extension if present
const cleanRepoName = repoName.endsWith('.git')
? repoName.slice(0, -4)
: repoName
const repo = await repoRegistry.get(cleanRepoName)
if (!repo) {
text(res, 'Repository not found', 404)
return
}
// Check if auth is enabled
if (!authEnabled) {
return gitProtocol.handleInfoRefs(req, res, username, cleanRepoName)
}
// Authenticate request (allow null for anonymous)
const auth = await authManager.authenticate(req).catch(err => { console.error('[Auth] Git auth error:', err.message); return null })
// Check repo access based on visibility
if (!authManager.hasRepoAccess(auth, repo, 'read')) {
console.log(`[Git] Denied info-refs: ${auth ? auth.method : 'anonymous'} lacks access to ${repo.visibility} repo ${cleanRepoName}`)
text(res, 'Repository not found or access denied', 403)
return
}
console.log(`[Git] Authorized info-refs: ${auth ? auth.method : 'anonymous'} for ${repo.visibility} repo ${cleanRepoName}`)
return gitProtocol.handleInfoRefs(req, res, username, cleanRepoName)
}
/**
* Handle git upload-pack (clone/fetch data transfer)
* Requires READ permission for private repos
*/
async function handleGitUploadPack(req, res, server) {
const { repoRegistry, gitProtocol } = server.repoData
const { authManager, authEnabled } = server.auth
const { username, repoName } = req.params
const cleanRepoName = repoName.endsWith('.git')
? repoName.slice(0, -4)
: repoName
const repo = await repoRegistry.get(cleanRepoName)
if (!repo) {
text(res, 'Repository not found', 404)
return
}
if (!authEnabled) {
return gitProtocol.handleUploadPack(req, res, username, cleanRepoName)
}
const auth = await authManager.authenticate(req).catch(err => { console.error('[Auth] Git auth error:', err.message); return null })
if (!authManager.hasRepoAccess(auth, repo, 'read')) {
console.log(`[Git] Denied upload-pack: ${auth ? auth.method : 'anonymous'} lacks access to ${repo.visibility} repo ${cleanRepoName}`)
text(res, 'Repository not found or access denied', 403)
return
}
console.log(`[Git] Authorized upload-pack: ${auth ? auth.method : 'anonymous'} for ${repo.visibility} repo ${cleanRepoName}`)
return gitProtocol.handleUploadPack(req, res, username, cleanRepoName)
}
/**
* Handle git receive-pack (push data transfer)
* Requires WRITE permission
*/
async function handleGitReceivePack(req, res, server) {
const { repoRegistry, gitProtocol } = server.repoData
const { authManager, authEnabled } = server.auth
const { username, repoName } = req.params
const cleanRepoName = repoName.endsWith('.git')
? repoName.slice(0, -4)
: repoName
const repo = await repoRegistry.get(cleanRepoName)
if (!repo) {
text(res, 'Repository not found', 404)
return
}
if (!authEnabled) {
await gitProtocol.handleReceivePack(req, res, username, cleanRepoName)
refreshRepoAfterPush(server, cleanRepoName)
broadcastRefUpdateAfterPush(server, cleanRepoName)
return
}
// Write requires authentication
const auth = await authManager.authenticate(req).catch(err => { console.error('[Auth] Git auth error:', err.message); return null })
if (!auth) {
console.log(`[Git] Denied receive-pack: Not authenticated for ${cleanRepoName}`)
res.statusCode = 401
res.setHeader('WWW-Authenticate', 'Basic realm="Git"')
res.setHeader('Content-Type', 'text/plain')
res.end('Authentication required: Use ?token=<token> or X-Wirecore-PublicKey header')
return
}
if (!authManager.hasRepoAccess(auth, repo, 'write')) {
console.log(`[Git] Denied receive-pack: ${auth.method} lacks write access to ${repo.visibility} repo ${cleanRepoName}`)
text(res, 'Repository not found or write access denied', 403)
return
}
console.log(`[Git] Authorized receive-pack: ${auth.method} for ${repo.visibility} repo ${cleanRepoName}`)
await gitProtocol.handleReceivePack(req, res, username, cleanRepoName)
// Sync Hyperdrive after push (if repo uses hyperdrive backend)
syncHyperdriveAfterPush(server, cleanRepoName)
// Auto-deploy after push (if configured, non-blocking)
autoDeployAfterPush(server, cleanRepoName)
// Refresh repo metadata (commits, branches, lastCommit) non-blocking
refreshRepoAfterPush(server, cleanRepoName)
// Broadcast ref update to federation peers via Protomux
broadcastRefUpdateAfterPush(server, cleanRepoName)
}
/**
* Sync Hyperdrive content after a git push (non-blocking)
*/
function syncHyperdriveAfterPush (server, repoName) {
const { backendManager } = server.repoData
backendManager.getBackend(repoName).then(backend => {
if (backend.getStrategy() === 'hyperdrive' && backend.importRepo) {
console.log(`[Hyperdrive] Syncing ${repoName} after push...`)
backend.importRepo(repoName).then(result => {
console.log(`[Hyperdrive] Synced ${repoName}: ${result.filesImported} files`)
}).catch(err => {
console.error(`[Hyperdrive] Sync error for ${repoName}:`, err.message)
})
}
}).catch(err => { console.error('[Hyperdrive] Sync lookup error:', err.message) })
}
/**
* Refresh repo metadata after push (non-blocking, fire-and-forget)
* Re-computes commits, branches, lastCommit and updates in-memory cache.
*/
function refreshRepoAfterPush (server, repoName) {
const { repoRegistry, repos } = server.repoData
repoRegistry.refreshRepoMetadata(repoName).then(updated => {
if (!updated) return
const idx = repos.findIndex(r => r.name === repoName)
if (idx >= 0) repos[idx] = updated
else repos.push(updated)
}).catch(() => {})
}
/**
* Broadcast ref update to federation peers via Protomux (non-blocking, fire-and-forget)
*/
function broadcastRefUpdateAfterPush (server, repoName) {
const { gitSyncManager } = server.p2p
const { repoRegistry, linkedRepos } = server.repoData
const { serverIdentity } = server.p2p
import('../git/ref-reader.js').then(({ readRefs }) => {
const repoPath = repoRegistry.gitReposPath + '/' + repoName + '.git'
return readRefs(repoPath)
}).then(async refs => {
// Write refs to LinkedRepo Autobase — replicates via wakeup protocol
const linked = linkedRepos.get(repoName)
if (linked) {
const userId = serverIdentity ? serverIdentity.getPublicKeyHex().slice(0, 16) : 'system'
for (const [ref, hash] of Object.entries(refs)) {
if (ref === 'HEAD') continue
try {
await linked.updateRef(ref, hash, userId)
} catch {}
}
console.log(`[GitSync] Wrote ${Object.keys(refs).length} refs to LinkedRepo for ${repoName}`)
}
// Also broadcast via Protomux for immediate notification
if (gitSyncManager) {
const serverKey = serverIdentity ? serverIdentity.getPublicKeyHex() : null
gitSyncManager.broadcastRefUpdate(repoName, refs, serverKey)
}
}).catch(err => {
console.error('[GitSync] Ref update failed:', err.message)
})
}
export {
handleGitInfoRefs,
handleGitUploadPack,
handleGitReceivePack,
refreshRepoAfterPush
}