mirror of
https://github.com/FalloutCollaborationProject/FCP-Mod-Updater.git
synced 2026-07-27 17:04:05 -07:00
Git Clone Progress
This commit is contained in:
+52
-3
@@ -1,11 +1,15 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using FCPModUpdater.Models;
|
||||
|
||||
namespace FCPModUpdater.Services;
|
||||
|
||||
public class GitService : IGitService
|
||||
public partial class GitService : IGitService
|
||||
{
|
||||
// Matches git progress output like: "Receiving objects: 45% (555/1234), 12.34 MiB | 1.23 MiB/s"
|
||||
[GeneratedRegex(@"(\d+)%\s*\((\d+)/(\d+)\)", RegexOptions.Compiled)]
|
||||
private static partial Regex GitProgressRegex();
|
||||
public async Task<bool> IsGitRepositoryAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, _) = await RunGitCommandAsync(path, "rev-parse --is-inside-work-tree", ct);
|
||||
@@ -126,14 +130,18 @@ public class GitService : IGitService
|
||||
}
|
||||
|
||||
public async Task<bool> CloneAsync(string url, string targetPath, IProgress<string>? progress = null,
|
||||
CancellationToken ct = default)
|
||||
IProgress<double>? percentProgress = null, CancellationToken ct = default)
|
||||
{
|
||||
progress?.Report($"Cloning {url}...");
|
||||
|
||||
var parentDir = Path.GetDirectoryName(targetPath) ?? ".";
|
||||
var folderName = Path.GetFileName(targetPath);
|
||||
|
||||
var (exitCode, _, error) = await RunGitCommandAsync(parentDir, $"clone \"{url}\" \"{folderName}\"", ct,
|
||||
var (exitCode, error) = await RunGitCommandWithProgressAsync(
|
||||
parentDir,
|
||||
$"clone --progress \"{url}\" \"{folderName}\"",
|
||||
percentProgress,
|
||||
ct,
|
||||
timeoutMs: 300000); // 5 minute timeout for clone
|
||||
|
||||
if (exitCode != 0)
|
||||
@@ -168,6 +176,47 @@ public class GitService : IGitService
|
||||
return exitCode == 0 && !string.IsNullOrWhiteSpace(output);
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string Error)> RunGitCommandWithProgressAsync(
|
||||
string workingDirectory, string arguments, IProgress<double>? percentProgress,
|
||||
CancellationToken ct, int timeoutMs = 30000)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeoutMs);
|
||||
|
||||
var errorLines = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await Cli.Wrap("git")
|
||||
.WithArguments(arguments)
|
||||
.WithWorkingDirectory(workingDirectory)
|
||||
.WithEnvironmentVariables(env => env
|
||||
.Set("GIT_TERMINAL_PROMPT", "0"))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithStandardOutputPipe(PipeTarget.Null)
|
||||
.WithStandardErrorPipe(PipeTarget.ToDelegate(line =>
|
||||
{
|
||||
errorLines.Add(line);
|
||||
|
||||
if (percentProgress == null)
|
||||
return;
|
||||
|
||||
var match = GitProgressRegex().Match(line);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var percent))
|
||||
{
|
||||
percentProgress.Report(percent);
|
||||
}
|
||||
}))
|
||||
.ExecuteAsync(cts.Token);
|
||||
|
||||
return (result.ExitCode, string.Join(Environment.NewLine, errorLines));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return (ExitCode: -1, Error: "Operation timed out or was cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GitCommitInfo> ParseCommitLog(string output)
|
||||
{
|
||||
var commits = new List<GitCommitInfo>();
|
||||
|
||||
@@ -12,7 +12,7 @@ public interface IGitService
|
||||
Task<IReadOnlyList<GitCommitInfo>> GetIncomingCommitsAsync(string path, int limit = 10, CancellationToken ct = default);
|
||||
Task<bool> FetchAsync(string path, IProgress<string>? progress = null, CancellationToken ct = default);
|
||||
Task<bool> PullAsync(string path, IProgress<string>? progress = null, CancellationToken ct = default);
|
||||
Task<bool> CloneAsync(string url, string targetPath, IProgress<string>? progress = null, CancellationToken ct = default);
|
||||
Task<bool> CloneAsync(string url, string targetPath, IProgress<string>? progress = null, IProgress<double>? percentProgress = null, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<string>> GetRemoteBranchesAsync(string path, CancellationToken ct = default);
|
||||
Task<bool> CheckoutAsync(string path, string branchOrCommit, CancellationToken ct = default);
|
||||
Task<bool> HasLocalChangesAsync(string path, CancellationToken ct = default);
|
||||
|
||||
+42
-65
@@ -201,12 +201,10 @@ public class InteractiveMenu
|
||||
async (repo, progress) =>
|
||||
{
|
||||
var targetPath = Path.Combine(_modsDirectory, repo.Name);
|
||||
progress.Report(10);
|
||||
|
||||
var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, targetPath, ct: ct);
|
||||
progress.Report(100);
|
||||
var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, targetPath, percentProgress: progress, ct: ct);
|
||||
|
||||
return (cloneOk, cloneOk ? null : "Clone failed");
|
||||
return (Success: cloneOk, Error: cloneOk ? null : "Clone failed");
|
||||
});
|
||||
|
||||
ModTableRenderer.RenderUpdateSummary(results);
|
||||
@@ -242,14 +240,15 @@ public class InteractiveMenu
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold red]WARNING: This will permanently delete the following mods:[/]");
|
||||
foreach (var mod in selected)
|
||||
foreach (InstalledMod mod in selected)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [red]• {mod.Name}[/] ({mod.Path})");
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (!AnsiConsole.Confirm("[red]Are you sure you want to delete these mods?[/]", defaultValue: false))
|
||||
if (!await AnsiConsole.ConfirmAsync("[red]Are you sure you want to delete these mods?[/]", defaultValue: false,
|
||||
cancellationToken: ct))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -265,7 +264,7 @@ public class InteractiveMenu
|
||||
|
||||
var results = new List<(string Name, bool Success, string? Error)>();
|
||||
|
||||
foreach (var mod in selected)
|
||||
foreach (InstalledMod mod in selected)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -288,7 +287,7 @@ public class InteractiveMenu
|
||||
private async Task<bool> HandleConvertAsync(CancellationToken ct)
|
||||
{
|
||||
var nonGitMods = _mods
|
||||
.Where(m => m.Source != ModSource.Git && !string.IsNullOrEmpty(m.MatchedRepoName))
|
||||
.Where(mod => mod.Source != ModSource.Git && !string.IsNullOrEmpty(mod.MatchedRepoName))
|
||||
.ToList();
|
||||
|
||||
if (nonGitMods.Count == 0)
|
||||
@@ -314,54 +313,36 @@ public class InteractiveMenu
|
||||
AnsiConsole.MarkupLine("[yellow]Warning: This will replace local folders with fresh git clones.[/]");
|
||||
AnsiConsole.MarkupLine("[yellow]Any local modifications (except About.xml changes) will be lost.[/]");
|
||||
|
||||
if (!AnsiConsole.Confirm("Proceed with conversion?"))
|
||||
if (!await AnsiConsole.ConfirmAsync("Proceed with conversion?", cancellationToken: ct))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var results = new List<(string Name, bool Success, string? Error)>();
|
||||
|
||||
foreach (var mod in selected)
|
||||
{
|
||||
var repo = await _gitHubApiService.GetRepoByNameAsync(mod.MatchedRepoName!, ct);
|
||||
if (repo == null)
|
||||
var results = await ProgressReporter.WithBatchProgressAsync(
|
||||
description: "Converting mods to Git",
|
||||
items: selected.ToList(),
|
||||
nameSelector: mod => mod.Name,
|
||||
action: async (mod, progress) =>
|
||||
{
|
||||
results.Add((mod.Name, false, "Repository not found"));
|
||||
continue;
|
||||
}
|
||||
RemoteRepo? repo = await _gitHubApiService.GetRepoByNameAsync(mod.MatchedRepoName!, ct);
|
||||
if (repo == null)
|
||||
return (Success: false, Error: "Repository not found");
|
||||
|
||||
// Backup About.xml if it exists and has modifications
|
||||
var aboutPath = Path.Combine(mod.Path, "About", "About.xml");
|
||||
string? aboutBackup = null;
|
||||
if (File.Exists(aboutPath))
|
||||
{
|
||||
aboutBackup = await File.ReadAllTextAsync(aboutPath, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Delete existing folder
|
||||
Directory.Delete(mod.Path, recursive: true);
|
||||
|
||||
// Clone fresh
|
||||
var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, mod.Path, ct: ct);
|
||||
|
||||
if (!cloneOk)
|
||||
try
|
||||
{
|
||||
results.Add((mod.Name, false, "Clone failed"));
|
||||
continue;
|
||||
// Delete existing folder
|
||||
Directory.Delete(mod.Path, recursive: true);
|
||||
|
||||
// Clone fresh with progress
|
||||
var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, mod.Path, percentProgress: progress, ct: ct);
|
||||
|
||||
return (Success: cloneOk, Error: cloneOk ? null : "Clone failed");
|
||||
}
|
||||
|
||||
// Note: We don't restore About.xml as it would create local modifications
|
||||
// The user should use the game's mod settings instead
|
||||
|
||||
results.Add((mod.Name, true, null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add((mod.Name, false, ex.Message));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (Success: false, Error: ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
ModTableRenderer.RenderUpdateSummary(results);
|
||||
WaitForKey();
|
||||
@@ -381,7 +362,7 @@ public class InteractiveMenu
|
||||
return false;
|
||||
}
|
||||
|
||||
var mod = AnsiConsole.Prompt(
|
||||
InstalledMod mod = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<InstalledMod>()
|
||||
.Title("[bold]Select a mod to manage:[/]")
|
||||
.PageSize(15)
|
||||
@@ -419,19 +400,20 @@ public class InteractiveMenu
|
||||
if (mod.HasLocalChanges)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Warning: You have local changes that may be affected.[/]");
|
||||
if (!AnsiConsole.Confirm("Continue anyway?"))
|
||||
if (!await AnsiConsole.ConfirmAsync("Continue anyway?", cancellationToken: ct))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (action == "Switch Branch")
|
||||
switch (action)
|
||||
{
|
||||
await HandleBranchSwitchAsync(mod, ct);
|
||||
}
|
||||
else if (action == "Checkout Specific Commit")
|
||||
{
|
||||
await HandleCommitCheckoutAsync(mod, ct);
|
||||
case "Switch Branch":
|
||||
await HandleBranchSwitchAsync(mod, ct);
|
||||
break;
|
||||
case "Checkout Specific Commit":
|
||||
await HandleCommitCheckoutAsync(mod, ct);
|
||||
break;
|
||||
}
|
||||
|
||||
await RefreshModsAsync(ct);
|
||||
@@ -465,14 +447,9 @@ public class InteractiveMenu
|
||||
$"Switching to {branch}...",
|
||||
async () => await _gitService.CheckoutAsync(mod.Path, branch, ct));
|
||||
|
||||
if (success)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Switched to branch '{branch}'[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Failed to switch to branch '{branch}'[/]");
|
||||
}
|
||||
AnsiConsole.MarkupLine(success
|
||||
? $"[green]Switched to branch '{branch}'[/]"
|
||||
: $"[red]Failed to switch to branch '{branch}'[/]");
|
||||
|
||||
WaitForKey();
|
||||
}
|
||||
@@ -490,7 +467,7 @@ public class InteractiveMenu
|
||||
return;
|
||||
}
|
||||
|
||||
var commit = AnsiConsole.Prompt(
|
||||
GitCommitInfo commit = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<GitCommitInfo>()
|
||||
.Title("Select commit:")
|
||||
.PageSize(15)
|
||||
|
||||
+13
-20
@@ -38,11 +38,11 @@ public static class ModTableRenderer
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
// Status summary
|
||||
var gitMods = mods.Where(m => m.Source == ModSource.Git).ToList();
|
||||
var upToDate = gitMods.Count(m => m.Status == ModStatus.UpToDate);
|
||||
var behind = gitMods.Count(m => m.Status == ModStatus.Behind);
|
||||
var localChanges = gitMods.Count(m => m.Status == ModStatus.LocalChanges);
|
||||
var nonGit = mods.Count(m => m.Source != ModSource.Git);
|
||||
var gitMods = mods.Where(mod => mod.Source == ModSource.Git).ToList();
|
||||
var upToDate = gitMods.Count(mod => mod.Status == ModStatus.UpToDate);
|
||||
var behind = gitMods.Count(mod => mod.Status == ModStatus.Behind);
|
||||
var localChanges = gitMods.Count(mod => mod.Status == ModStatus.LocalChanges);
|
||||
var nonGit = mods.Count(mod => mod.Source != ModSource.Git);
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine(
|
||||
@@ -91,10 +91,9 @@ public static class ModTableRenderer
|
||||
|
||||
private static string FormatCommit(GitCommitInfo? commit)
|
||||
{
|
||||
if (commit == null)
|
||||
return "[grey]-[/]";
|
||||
|
||||
return $"[grey]{commit.ShortHash}[/]";
|
||||
return commit != null
|
||||
? $"[grey]{commit.ShortHash}[/]"
|
||||
: "[grey]-[/]";
|
||||
}
|
||||
|
||||
private static string FormatStatus(InstalledMod mod)
|
||||
@@ -117,7 +116,7 @@ public static class ModTableRenderer
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var table = new Table()
|
||||
Table table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderColor(Color.Grey)
|
||||
.Title("[bold]Update Results[/]")
|
||||
@@ -139,15 +138,9 @@ public static class ModTableRenderer
|
||||
var failCount = results.Count(r => !r.Success);
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
if (failCount == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Successfully updated {successCount} mod(s).[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine(
|
||||
$"[yellow]Updated {successCount} mod(s), {failCount} failed.[/]");
|
||||
}
|
||||
AnsiConsole.MarkupLine(failCount == 0
|
||||
? $"[green]Successfully updated {successCount} mod(s).[/]"
|
||||
: $"[yellow]Updated {successCount} mod(s), {failCount} failed.[/]");
|
||||
}
|
||||
|
||||
public static void RenderIncomingCommits(InstalledMod mod, IReadOnlyList<GitCommitInfo> commits)
|
||||
@@ -160,7 +153,7 @@ public static class ModTableRenderer
|
||||
|
||||
var tree = new Tree($"[bold]{mod.Name}[/] [grey]({commits.Count} incoming commits)[/]");
|
||||
|
||||
foreach (var commit in commits)
|
||||
foreach (GitCommitInfo commit in commits)
|
||||
{
|
||||
var dateStr = commit.Date.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
||||
tree.AddNode(
|
||||
|
||||
Reference in New Issue
Block a user