Git Clone Progress

This commit is contained in:
Canon
2026-02-04 11:56:23 +08:00
parent 0123b2104d
commit cb19220937
4 changed files with 108 additions and 89 deletions
+52 -3
View File
@@ -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>();
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(