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;
|
|
|
|
|
}
|
|
|
|
|
}
|