'use strict'; const fs = require('node:fs/promises'); const path = require('node:path'); const { matchRemoteToRepository, repositoryKey } = require('../shared/repository-match.cjs'); const SKIP_DIRECTORIES = new Set([ '.git', '.svn', '.hg', 'node_modules', '.next', '.nuxt', 'dist', 'build', 'coverage', '.cache', '.venv', 'venv', '__pycache__', '$RECYCLE.BIN', 'System Volume Information' ]); async function mapLimit(items, limit, mapper) { const output = new Array(items.length); let cursor = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; output[index] = await mapper(items[index], index); } }); await Promise.all(workers); return output; } class RepositoryService { constructor(store, gitService, giteaService, diagnostics = null) { this.store = store; this.git = gitService; this.gitea = giteaService; this.diagnostics = diagnostics; this.lastKnownLocalPaths = []; this.lastKnownRemoteRepositories = []; this.lastSuccessfulRemoteRefreshAt = null; this.lastRemoteRefreshAtMs = 0; this.lastDiscoveredPaths = []; this.lastDiscoveryAtMs = 0; this.refreshPromise = null; this.lastResult = null; } async discoverInRoot(root, maxDepth = 4) { const found = []; const seen = new Set(); const visit = async (directory, depth) => { let real; try { real = await fs.realpath(directory); } catch { return; } if (seen.has(real)) return; seen.add(real); const gitMarker = path.join(directory, '.git'); const marker = await fs.stat(gitMarker).catch(() => null); if (marker) { found.push(real); return; } if (depth >= maxDepth) return; let entries; try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; } // Directory entries report as a symbolic link instead of a directory, which // is how Windows junctions surface. Skipping those made a project folder // that is mapped through a junction invisible; visit() resolves each entry // and the `seen` set above keeps links that point back into the tree from // being scanned twice. await mapLimit(entries .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12, (entry) => visit(path.join(real, entry.name), depth + 1)); }; await visit(root, 0); return found; } async discoverAll(roots) { const grouped = await mapLimit((roots || []).filter(Boolean), 4, (root) => this.discoverInRoot(root)); return [...new Set(grouped.flat())]; } async getLocalDescriptors(paths) { return mapLimit(paths, 5, async (localPath) => { try { const status = await this.git.status(localPath); return { localPath: status.root, remoteUrl: status.remoteUrl, status }; } catch (error) { return { localPath, remoteUrl: '', status: null, error: error.message }; } }); } getWatchPaths() { return [...this.lastKnownLocalPaths]; } async getRemoteRepositories({ force = false } = {}) { if (!this.store.data.gitea.baseUrl || !this.store.getToken()) { this.lastKnownRemoteRepositories = []; this.lastSuccessfulRemoteRefreshAt = null; return { repositories: [], stale: false, error: null }; } if (!force && this.lastSuccessfulRemoteRefreshAt && Date.now() - this.lastRemoteRefreshAtMs < 15_000) { return { repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })), stale: false, error: null, cached: true }; } try { const repositories = await this.gitea.listRepositories(); this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository })); this.lastSuccessfulRemoteRefreshAt = new Date().toISOString(); this.lastRemoteRefreshAtMs = Date.now(); return { repositories, stale: false, error: null }; } catch (error) { if (!this.lastSuccessfulRemoteRefreshAt) throw error; await this.diagnostics?.warning('repositories.remote-refresh.degraded', { message: error.message, cachedCount: this.lastKnownRemoteRepositories.length, lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt }); return { repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })), stale: true, error: error.message }; } } async getDiscoveredPaths({ force = false } = {}) { if (!force && this.lastDiscoveryAtMs && Date.now() - this.lastDiscoveryAtMs < 30_000) { return [...this.lastDiscoveredPaths]; } const paths = await this.discoverAll(this.store.data.workspaceRoots); this.lastDiscoveredPaths = [...paths]; this.lastDiscoveryAtMs = Date.now(); return paths; } // Resolving a single repository used to go through a full refresh, which runs // `git status` for every discovered repository. Handlers that act on one // repository only need that one, so its local state is read directly. Anything // this cannot answer confidently still falls back to the full scan. async resolveByFullName(fullName) { const name = String(fullName || '').trim(); if (!name) return null; const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null; const remoteResult = await this.getRemoteRepositories({}); const remote = remoteResult.repositories.find((item) => item.full_name === name); if (!remote) return fromFullRefresh(); const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)]; const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null; // Without a known path the link can still exist through remote-URL matching, // which only the discovery pass can establish. if (!knownPath && !this.lastResult) return fromFullRefresh(); const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null; const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({ ...profile, state: this.store.getDeploymentState(profile.id) })); return { ...this.decorate(remote, local, profiles), remoteStale: remoteResult.stale, remoteRefreshError: remoteResult.error, remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt }; } async refresh(options = {}) { if (this.refreshPromise) return this.refreshPromise; this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; }); return this.refreshPromise; } async performRefresh({ force = false } = {}) { const started = Date.now(); const remoteResult = await this.getRemoteRepositories({ force }); const remoteRepositories = remoteResult.repositories; const discoveredPaths = await this.getDiscoveredPaths({ force }); const mappedPaths = Object.values(this.store.data.repositoryMappings || {}); const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])]; const localDescriptors = await this.getLocalDescriptors(localPaths); this.lastKnownLocalPaths = localDescriptors.filter((item) => item.status).map((item) => item.status.root); const usedLocalPaths = new Set(); const repositories = []; for (const remote of remoteRepositories) { const key = repositoryKey(remote); const explicitPath = this.store.data.repositoryMappings[key]; let local = explicitPath ? localDescriptors.find((item) => path.resolve(item.localPath) === path.resolve(explicitPath)) : null; if (!local) local = localDescriptors.find((item) => !usedLocalPaths.has(item.localPath) && matchRemoteToRepository(item.remoteUrl, [remote])); if (local) usedLocalPaths.add(local.localPath); const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({ ...profile, state: this.store.getDeploymentState(profile.id) })); repositories.push({ ...this.decorate(remote, local, profiles), remoteStale: remoteResult.stale, remoteRefreshError: remoteResult.error, remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt }); } for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) { const name = path.basename(local.localPath); repositories.push({ id: `local:${local.localPath}`, name, fullName: name, owner: { login: 'local' }, description: 'Local repository not matched to Gitea', private: true, defaultBranch: local.status?.branch.head || 'main', htmlUrl: null, cloneUrl: null, sshUrl: null, preferredCloneUrl: null, localPath: local.localPath, localStatus: local.status, linkState: 'unmatched-local', deploymentProfiles: [], readyToDeploy: false, favorite: false, attention: Boolean(local.error), attentionReason: local.error || null }); } const sorted = repositories.sort((a, b) => { const score = (repo) => (repo.attention ? 100 : 0) + (repo.localStatus?.counts.changed ? 50 : 0) + (repo.localStatus?.branch.ahead ? 30 : 0) + (repo.readyToDeploy ? 20 : 0) + (repo.favorite ? 5 : 0); return score(b) - score(a) || a.fullName.localeCompare(b.fullName); }); await this.diagnostics?.debug('repositories.refresh.completed', { durationMs: Date.now() - started, remoteCount: remoteRepositories.length, remoteStale: remoteResult.stale, remoteCached: remoteResult.cached === true, discoveredCount: discoveredPaths.length, linkedCount: sorted.filter((item) => item.localPath).length, attentionCount: sorted.filter((item) => item.attention).length, readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length }); this.lastResult = sorted; return sorted; } decorate(remote, local, profiles) { const status = local?.status || null; const hasChanges = Boolean(status?.counts.changed); const ahead = status?.branch.ahead || 0; const behind = status?.branch.behind || 0; const conflict = Boolean(status?.counts.conflicts); const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head); const synchronized = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0); const alreadyLiveAndHealthy = Boolean( synchronized && profileForBranch?.state?.liveSha === status.head && profileForBranch?.state?.healthy !== false ); const readyToDeploy = synchronized && !alreadyLiveAndHealthy; const key = String(remote.full_name || '').toLowerCase(); const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh' ? (remote.ssh_url || remote.clone_url) : (remote.clone_url || remote.ssh_url); return { id: remote.id, name: remote.name, fullName: remote.full_name, owner: remote.owner, description: remote.description || '', private: remote.private, defaultBranch: remote.default_branch || 'main', htmlUrl: remote.html_url, cloneUrl: remote.clone_url, sshUrl: remote.ssh_url, preferredCloneUrl, updatedAt: remote.updated_at, localPath: local?.localPath || null, localStatus: status, linkState: local ? 'linked' : 'remote-only', deploymentProfiles: profiles, readyToDeploy, favorite: (this.store.data.favorites || []).includes(key), attention: conflict || behind > 0 || Boolean(local?.error), attentionReason: conflict ? 'Merge conflict' : behind > 0 ? `${behind} commit${behind === 1 ? '' : 's'} behind remote` : local?.error || null }; } } module.exports = { RepositoryService, SKIP_DIRECTORIES, mapLimit };