Files
FCP-Mod-Updater/Services/ModDiscoveryService.cs
T

225 lines
7.0 KiB
C#
Raw Normal View History

2026-02-04 14:41:10 +08:00
using System.Collections.Concurrent;
2026-02-04 08:58:36 +08:00
using FCPModUpdater.Models;
namespace FCPModUpdater.Services;
public class ModDiscoveryService : IModDiscoveryService
{
private const string FcpOrgUrl = "github.com/FalloutCollaborationProject/";
2026-02-04 11:44:21 +08:00
private static readonly string[] BranchSuffixes = ["-main", "-master", "-develop"];
2026-02-04 08:58:36 +08:00
private readonly IGitService _gitService;
private readonly IGitHubApiService _gitHubApiService;
public ModDiscoveryService(IGitService gitService, IGitHubApiService gitHubApiService)
{
_gitService = gitService;
_gitHubApiService = gitHubApiService;
}
public async Task<IReadOnlyList<InstalledMod>> DiscoverModsAsync(
string modsDirectory,
IProgress<string>? progress = null,
CancellationToken ct = default)
{
if (!Directory.Exists(modsDirectory))
{
return [];
}
var orgRepos = await _gitHubApiService.GetOrganizationReposAsync(ct);
var orgRepoNames = orgRepos.Select(r => r.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
var directories = Directory.GetDirectories(modsDirectory);
2026-02-04 14:41:10 +08:00
var mods = new ConcurrentBag<InstalledMod>();
2026-02-04 08:58:36 +08:00
2026-02-04 14:41:10 +08:00
await Parallel.ForEachAsync(directories,
new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = ct },
async (dir, token) =>
2026-02-04 08:58:36 +08:00
{
2026-02-04 14:41:10 +08:00
var folderName = Path.GetFileName(dir);
progress?.Report($"Scanning: {folderName}");
InstalledMod? mod = await AnalyzeModDirectoryAsync(dir, folderName, orgRepoNames, token);
if (mod != null)
{
mods.Add(mod);
}
});
2026-02-04 08:58:36 +08:00
return mods.OrderBy(m => m.Name).ToList();
}
private async Task<InstalledMod?> AnalyzeModDirectoryAsync(
string path,
string folderName,
HashSet<string> orgRepoNames,
CancellationToken ct)
{
var isGitRepo = await _gitService.IsGitRepositoryAsync(path, ct);
if (!isGitRepo)
{
// Not a git repo - check if it matches an org repo by name
2026-02-04 11:44:21 +08:00
var matchedRepo = MatchRepoName(folderName, orgRepoNames);
if (matchedRepo != null)
2026-02-04 08:58:36 +08:00
{
return new InstalledMod
{
Name = folderName,
Path = path,
2026-02-04 11:44:21 +08:00
Source = ModSource.Local,
2026-02-04 08:58:36 +08:00
Status = ModStatus.NonGit,
2026-02-04 11:44:21 +08:00
MatchedRepoName = matchedRepo
2026-02-04 08:58:36 +08:00
};
}
return null;
}
// It's a git repo - check if it's an FCP mod
var remoteUrl = await _gitService.GetRemoteUrlAsync(path, ct);
if (string.IsNullOrEmpty(remoteUrl))
{
// Git repo without remote - check if folder matches org repo
2026-02-04 11:44:21 +08:00
var matchedRepo = MatchRepoName(folderName, orgRepoNames);
if (matchedRepo != null)
2026-02-04 08:58:36 +08:00
{
return new InstalledMod
{
Name = folderName,
Path = path,
Source = ModSource.Git,
Status = ModStatus.Error,
ErrorMessage = "Git repository has no remote configured",
2026-02-04 11:44:21 +08:00
MatchedRepoName = matchedRepo
2026-02-04 08:58:36 +08:00
};
}
return null;
}
2026-02-04 09:17:59 +08:00
var isFcpMod = remoteUrl.Contains(FcpOrgUrl, StringComparison.OrdinalIgnoreCase);
2026-02-04 08:58:36 +08:00
if (!isFcpMod)
{
// Check if folder name matches org repo (might be cloned from fork)
2026-02-04 11:44:21 +08:00
if (MatchRepoName(folderName, orgRepoNames) == null)
2026-02-04 08:58:36 +08:00
{
return null;
}
}
// It's an FCP mod - gather full status
return await BuildInstalledModAsync(path, folderName, remoteUrl, ct);
}
private async Task<InstalledMod> BuildInstalledModAsync(
string path,
string folderName,
string remoteUrl,
CancellationToken ct)
{
try
{
2026-02-04 14:41:10 +08:00
// Phase 1: Run all independent operations in parallel
var branchTask = _gitService.GetCurrentBranchAsync(path, ct);
var commitTask = _gitService.GetCurrentCommitAsync(path, ct);
var changesTask = _gitService.HasLocalChangesAsync(path, ct);
var fetchTask = _gitService.FetchAsync(path, ct: ct);
2026-02-04 08:58:36 +08:00
2026-02-04 14:41:10 +08:00
await Task.WhenAll(branchTask, commitTask, changesTask, fetchTask);
2026-02-04 08:58:36 +08:00
2026-02-04 14:41:10 +08:00
string? branch = branchTask.Result;
GitCommitInfo? currentCommit = commitTask.Result;
bool hasLocalChanges = changesTask.Result;
// Phase 2: Get commit difference (needs fetch to complete first)
2026-02-04 08:58:36 +08:00
(int behind, int ahead) = await _gitService.GetCommitDifferenceAsync(path, ct);
ModStatus status = DetermineStatus(behind, ahead, hasLocalChanges, branch);
return new InstalledMod
{
Name = folderName,
Path = path,
Source = ModSource.Git,
RemoteUrl = remoteUrl,
Branch = branch,
CurrentCommit = currentCommit,
Status = status,
CommitsBehind = behind,
CommitsAhead = ahead,
HasLocalChanges = hasLocalChanges
};
}
catch (Exception ex)
{
return new InstalledMod
{
Name = folderName,
Path = path,
Source = ModSource.Git,
RemoteUrl = remoteUrl,
Status = ModStatus.Error,
ErrorMessage = ex.Message
};
}
}
2026-02-04 11:44:21 +08:00
/// <summary>
/// Tries to match a folder name to an org repo, accounting for GitHub ZIP download suffixes like -main, -master.
/// </summary>
private static string? MatchRepoName(string folderName, HashSet<string> orgRepoNames)
{
// Direct match
if (orgRepoNames.Contains(folderName))
return folderName;
// Try stripping common branch suffixes (for ZIP downloads)
foreach (var suffix in BranchSuffixes)
{
if (folderName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
var baseName = folderName[..^suffix.Length];
if (orgRepoNames.Contains(baseName))
return baseName;
}
}
return null;
}
2026-02-04 08:58:36 +08:00
private static ModStatus DetermineStatus(int behind, int ahead, bool hasLocalChanges, string? branch)
{
if (branch == null)
{
// Detached HEAD
return ModStatus.Unknown;
}
if (hasLocalChanges)
{
return ModStatus.LocalChanges;
}
if (behind > 0 && ahead > 0)
{
return ModStatus.Diverged;
}
if (behind > 0)
{
return ModStatus.Behind;
}
if (ahead > 0)
{
return ModStatus.Ahead;
}
return ModStatus.UpToDate;
}
}