diff --git a/Services/GitService.cs b/Services/GitService.cs index ffc8117..f561535 100644 --- a/Services/GitService.cs +++ b/Services/GitService.cs @@ -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 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 CloneAsync(string url, string targetPath, IProgress? progress = null, - CancellationToken ct = default) + IProgress? 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? percentProgress, + CancellationToken ct, int timeoutMs = 30000) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeoutMs); + + var errorLines = new List(); + + 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 ParseCommitLog(string output) { var commits = new List(); diff --git a/Services/IGitService.cs b/Services/IGitService.cs index c89be85..bc1b172 100644 --- a/Services/IGitService.cs +++ b/Services/IGitService.cs @@ -12,7 +12,7 @@ public interface IGitService Task> GetIncomingCommitsAsync(string path, int limit = 10, CancellationToken ct = default); Task FetchAsync(string path, IProgress? progress = null, CancellationToken ct = default); Task PullAsync(string path, IProgress? progress = null, CancellationToken ct = default); - Task CloneAsync(string url, string targetPath, IProgress? progress = null, CancellationToken ct = default); + Task CloneAsync(string url, string targetPath, IProgress? progress = null, IProgress? percentProgress = null, CancellationToken ct = default); Task> GetRemoteBranchesAsync(string path, CancellationToken ct = default); Task CheckoutAsync(string path, string branchOrCommit, CancellationToken ct = default); Task HasLocalChangesAsync(string path, CancellationToken ct = default); diff --git a/UI/InteractiveMenu.cs b/UI/InteractiveMenu.cs index b8fc7fc..83f824e 100644 --- a/UI/InteractiveMenu.cs +++ b/UI/InteractiveMenu.cs @@ -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 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() .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() .Title("Select commit:") .PageSize(15) diff --git a/UI/ModTableRenderer.cs b/UI/ModTableRenderer.cs index c6062e9..f9ff582 100644 --- a/UI/ModTableRenderer.cs +++ b/UI/ModTableRenderer.cs @@ -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 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(