Update
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
'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 = [];
|
||||
}
|
||||
|
||||
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; }
|
||||
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 refresh() {
|
||||
const started = Date.now();
|
||||
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
|
||||
? await this.gitea.listRepositories()
|
||||
: [];
|
||||
|
||||
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
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));
|
||||
}
|
||||
|
||||
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,
|
||||
discoveredCount: discoveredPaths.length,
|
||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
||||
});
|
||||
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 readyToDeploy = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
|
||||
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 };
|
||||
Reference in New Issue
Block a user