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;
|
||||||
using CliWrap.Buffered;
|
using CliWrap.Buffered;
|
||||||
using FCPModUpdater.Models;
|
using FCPModUpdater.Models;
|
||||||
|
|
||||||
namespace FCPModUpdater.Services;
|
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)
|
public async Task<bool> IsGitRepositoryAsync(string path, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, _) = await RunGitCommandAsync(path, "rev-parse --is-inside-work-tree", ct);
|
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,
|
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}...");
|
progress?.Report($"Cloning {url}...");
|
||||||
|
|
||||||
var parentDir = Path.GetDirectoryName(targetPath) ?? ".";
|
var parentDir = Path.GetDirectoryName(targetPath) ?? ".";
|
||||||
var folderName = Path.GetFileName(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
|
timeoutMs: 300000); // 5 minute timeout for clone
|
||||||
|
|
||||||
if (exitCode != 0)
|
if (exitCode != 0)
|
||||||
@@ -168,6 +176,47 @@ public class GitService : IGitService
|
|||||||
return exitCode == 0 && !string.IsNullOrWhiteSpace(output);
|
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)
|
private static IReadOnlyList<GitCommitInfo> ParseCommitLog(string output)
|
||||||
{
|
{
|
||||||
var commits = new List<GitCommitInfo>();
|
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<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> FetchAsync(string path, IProgress<string>? progress = null, CancellationToken ct = default);
|
||||||
Task<bool> PullAsync(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<IReadOnlyList<string>> GetRemoteBranchesAsync(string path, CancellationToken ct = default);
|
||||||
Task<bool> CheckoutAsync(string path, string branchOrCommit, CancellationToken ct = default);
|
Task<bool> CheckoutAsync(string path, string branchOrCommit, CancellationToken ct = default);
|
||||||
Task<bool> HasLocalChangesAsync(string path, CancellationToken ct = default);
|
Task<bool> HasLocalChangesAsync(string path, CancellationToken ct = default);
|
||||||
|
|||||||
+42
-65
@@ -201,12 +201,10 @@ public class InteractiveMenu
|
|||||||
async (repo, progress) =>
|
async (repo, progress) =>
|
||||||
{
|
{
|
||||||
var targetPath = Path.Combine(_modsDirectory, repo.Name);
|
var targetPath = Path.Combine(_modsDirectory, repo.Name);
|
||||||
progress.Report(10);
|
|
||||||
|
|
||||||
var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, targetPath, ct: ct);
|
var cloneOk = await _gitService.CloneAsync(repo.CloneUrl, targetPath, percentProgress: progress, ct: ct);
|
||||||
progress.Report(100);
|
|
||||||
|
|
||||||
return (cloneOk, cloneOk ? null : "Clone failed");
|
return (Success: cloneOk, Error: cloneOk ? null : "Clone failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
ModTableRenderer.RenderUpdateSummary(results);
|
ModTableRenderer.RenderUpdateSummary(results);
|
||||||
@@ -242,14 +240,15 @@ public class InteractiveMenu
|
|||||||
|
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
AnsiConsole.MarkupLine("[bold red]WARNING: This will permanently delete the following mods:[/]");
|
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.MarkupLine($" [red]• {mod.Name}[/] ({mod.Path})");
|
||||||
}
|
}
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -265,7 +264,7 @@ public class InteractiveMenu
|
|||||||
|
|
||||||
var results = new List<(string Name, bool Success, string? Error)>();
|
var results = new List<(string Name, bool Success, string? Error)>();
|
||||||
|
|
||||||
foreach (var mod in selected)
|
foreach (InstalledMod mod in selected)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -288,7 +287,7 @@ public class InteractiveMenu
|
|||||||
private async Task<bool> HandleConvertAsync(CancellationToken ct)
|
private async Task<bool> HandleConvertAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var nonGitMods = _mods
|
var nonGitMods = _mods
|
||||||
.Where(m => m.Source != ModSource.Git && !string.IsNullOrEmpty(m.MatchedRepoName))
|
.Where(mod => mod.Source != ModSource.Git && !string.IsNullOrEmpty(mod.MatchedRepoName))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (nonGitMods.Count == 0)
|
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]Warning: This will replace local folders with fresh git clones.[/]");
|
||||||
AnsiConsole.MarkupLine("[yellow]Any local modifications (except About.xml changes) will be lost.[/]");
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = new List<(string Name, bool Success, string? Error)>();
|
var results = await ProgressReporter.WithBatchProgressAsync(
|
||||||
|
description: "Converting mods to Git",
|
||||||
foreach (var mod in selected)
|
items: selected.ToList(),
|
||||||
{
|
nameSelector: mod => mod.Name,
|
||||||
var repo = await _gitHubApiService.GetRepoByNameAsync(mod.MatchedRepoName!, ct);
|
action: async (mod, progress) =>
|
||||||
if (repo == null)
|
|
||||||
{
|
{
|
||||||
results.Add((mod.Name, false, "Repository not found"));
|
RemoteRepo? repo = await _gitHubApiService.GetRepoByNameAsync(mod.MatchedRepoName!, ct);
|
||||||
continue;
|
if (repo == null)
|
||||||
}
|
return (Success: false, Error: "Repository not found");
|
||||||
|
|
||||||
// Backup About.xml if it exists and has modifications
|
try
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
results.Add((mod.Name, false, "Clone failed"));
|
// Delete existing folder
|
||||||
continue;
|
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");
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
// Note: We don't restore About.xml as it would create local modifications
|
{
|
||||||
// The user should use the game's mod settings instead
|
return (Success: false, Error: ex.Message);
|
||||||
|
}
|
||||||
results.Add((mod.Name, true, null));
|
});
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
results.Add((mod.Name, false, ex.Message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ModTableRenderer.RenderUpdateSummary(results);
|
ModTableRenderer.RenderUpdateSummary(results);
|
||||||
WaitForKey();
|
WaitForKey();
|
||||||
@@ -381,7 +362,7 @@ public class InteractiveMenu
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mod = AnsiConsole.Prompt(
|
InstalledMod mod = AnsiConsole.Prompt(
|
||||||
new SelectionPrompt<InstalledMod>()
|
new SelectionPrompt<InstalledMod>()
|
||||||
.Title("[bold]Select a mod to manage:[/]")
|
.Title("[bold]Select a mod to manage:[/]")
|
||||||
.PageSize(15)
|
.PageSize(15)
|
||||||
@@ -419,19 +400,20 @@ public class InteractiveMenu
|
|||||||
if (mod.HasLocalChanges)
|
if (mod.HasLocalChanges)
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine("[yellow]Warning: You have local changes that may be affected.[/]");
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action == "Switch Branch")
|
switch (action)
|
||||||
{
|
{
|
||||||
await HandleBranchSwitchAsync(mod, ct);
|
case "Switch Branch":
|
||||||
}
|
await HandleBranchSwitchAsync(mod, ct);
|
||||||
else if (action == "Checkout Specific Commit")
|
break;
|
||||||
{
|
case "Checkout Specific Commit":
|
||||||
await HandleCommitCheckoutAsync(mod, ct);
|
await HandleCommitCheckoutAsync(mod, ct);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await RefreshModsAsync(ct);
|
await RefreshModsAsync(ct);
|
||||||
@@ -465,14 +447,9 @@ public class InteractiveMenu
|
|||||||
$"Switching to {branch}...",
|
$"Switching to {branch}...",
|
||||||
async () => await _gitService.CheckoutAsync(mod.Path, branch, ct));
|
async () => await _gitService.CheckoutAsync(mod.Path, branch, ct));
|
||||||
|
|
||||||
if (success)
|
AnsiConsole.MarkupLine(success
|
||||||
{
|
? $"[green]Switched to branch '{branch}'[/]"
|
||||||
AnsiConsole.MarkupLine($"[green]Switched to branch '{branch}'[/]");
|
: $"[red]Failed to switch to branch '{branch}'[/]");
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine($"[red]Failed to switch to branch '{branch}'[/]");
|
|
||||||
}
|
|
||||||
|
|
||||||
WaitForKey();
|
WaitForKey();
|
||||||
}
|
}
|
||||||
@@ -490,7 +467,7 @@ public class InteractiveMenu
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var commit = AnsiConsole.Prompt(
|
GitCommitInfo commit = AnsiConsole.Prompt(
|
||||||
new SelectionPrompt<GitCommitInfo>()
|
new SelectionPrompt<GitCommitInfo>()
|
||||||
.Title("Select commit:")
|
.Title("Select commit:")
|
||||||
.PageSize(15)
|
.PageSize(15)
|
||||||
|
|||||||
+13
-20
@@ -38,11 +38,11 @@ public static class ModTableRenderer
|
|||||||
AnsiConsole.Write(table);
|
AnsiConsole.Write(table);
|
||||||
|
|
||||||
// Status summary
|
// Status summary
|
||||||
var gitMods = mods.Where(m => m.Source == ModSource.Git).ToList();
|
var gitMods = mods.Where(mod => mod.Source == ModSource.Git).ToList();
|
||||||
var upToDate = gitMods.Count(m => m.Status == ModStatus.UpToDate);
|
var upToDate = gitMods.Count(mod => mod.Status == ModStatus.UpToDate);
|
||||||
var behind = gitMods.Count(m => m.Status == ModStatus.Behind);
|
var behind = gitMods.Count(mod => mod.Status == ModStatus.Behind);
|
||||||
var localChanges = gitMods.Count(m => m.Status == ModStatus.LocalChanges);
|
var localChanges = gitMods.Count(mod => mod.Status == ModStatus.LocalChanges);
|
||||||
var nonGit = mods.Count(m => m.Source != ModSource.Git);
|
var nonGit = mods.Count(mod => mod.Source != ModSource.Git);
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
AnsiConsole.MarkupLine(
|
AnsiConsole.MarkupLine(
|
||||||
@@ -91,10 +91,9 @@ public static class ModTableRenderer
|
|||||||
|
|
||||||
private static string FormatCommit(GitCommitInfo? commit)
|
private static string FormatCommit(GitCommitInfo? commit)
|
||||||
{
|
{
|
||||||
if (commit == null)
|
return commit != null
|
||||||
return "[grey]-[/]";
|
? $"[grey]{commit.ShortHash}[/]"
|
||||||
|
: "[grey]-[/]";
|
||||||
return $"[grey]{commit.ShortHash}[/]";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatStatus(InstalledMod mod)
|
private static string FormatStatus(InstalledMod mod)
|
||||||
@@ -117,7 +116,7 @@ public static class ModTableRenderer
|
|||||||
{
|
{
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
var table = new Table()
|
Table table = new Table()
|
||||||
.Border(TableBorder.Rounded)
|
.Border(TableBorder.Rounded)
|
||||||
.BorderColor(Color.Grey)
|
.BorderColor(Color.Grey)
|
||||||
.Title("[bold]Update Results[/]")
|
.Title("[bold]Update Results[/]")
|
||||||
@@ -139,15 +138,9 @@ public static class ModTableRenderer
|
|||||||
var failCount = results.Count(r => !r.Success);
|
var failCount = results.Count(r => !r.Success);
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
if (failCount == 0)
|
AnsiConsole.MarkupLine(failCount == 0
|
||||||
{
|
? $"[green]Successfully updated {successCount} mod(s).[/]"
|
||||||
AnsiConsole.MarkupLine($"[green]Successfully updated {successCount} mod(s).[/]");
|
: $"[yellow]Updated {successCount} mod(s), {failCount} failed.[/]");
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[yellow]Updated {successCount} mod(s), {failCount} failed.[/]");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RenderIncomingCommits(InstalledMod mod, IReadOnlyList<GitCommitInfo> commits)
|
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)[/]");
|
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");
|
var dateStr = commit.Date.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
||||||
tree.AddNode(
|
tree.AddNode(
|
||||||
|
|||||||
Reference in New Issue
Block a user