Improve git update error reporting

This commit is contained in:
Canon
2026-05-22 15:00:03 +08:00
parent 1402e192d7
commit c01df3012e
9 changed files with 252 additions and 44 deletions
+1 -13
View File
@@ -53,19 +53,7 @@ public class UpdateCommand(
"Updating mods",
updateableMods,
installedMod => installedMod.Name,
async (mod, progress) =>
{
progress.Report(25);
var fetchOk = await gitService.FetchAsync(mod.Path, ct: ct);
if (!fetchOk)
return (false, "Fetch failed");
progress.Report(50);
var pullOk = await gitService.PullAsync(mod.Path, ct: ct);
progress.Report(100);
return (pullOk, pullOk ? null : "Pull failed");
});
async (mod, progress) => await GitModUpdater.UpdateAsync(gitService, mod, progress, ct));
ModTableRenderer.RenderUpdateSummary(results);
@@ -0,0 +1,106 @@
using NSubstitute;
namespace FCPModUpdater.Tests.Services;
public class GitModUpdaterTests
{
private readonly IGitService _gitService = Substitute.For<IGitService>();
private static InstalledMod MakeMod() => new()
{
Name = "TestMod",
Path = "/mods/TestMod",
Source = ModSource.Git,
Status = ModStatus.Behind,
CommitsBehind = 1
};
[Fact]
public async Task UpdateAsync_FetchFailure_ReturnsModSpecificError()
{
CancellationToken token = TestContext.Current.CancellationToken;
InstalledMod mod = MakeMod();
_gitService.FetchAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(new GitOperationResult(false, 128, "fatal: fetch failed"));
_gitService.GetCommitDifferenceAsync(mod.Path, token)
.Returns((Behind: 1, Ahead: 0));
var result = await GitModUpdater.UpdateAsync(_gitService, mod, ct: token);
Assert.False(result.Success);
Assert.Equal("Fetch failed for TestMod: fatal: fetch failed", result.Error);
}
[Fact]
public async Task UpdateAsync_PullFailure_ReturnsModSpecificError()
{
CancellationToken token = TestContext.Current.CancellationToken;
InstalledMod mod = MakeMod();
_gitService.FetchAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(new GitOperationResult(true, 0, null));
_gitService.PullAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(new GitOperationResult(false, 128, "fatal: pull failed"));
_gitService.GetCommitDifferenceAsync(mod.Path, token)
.Returns((Behind: 1, Ahead: 0));
var result = await GitModUpdater.UpdateAsync(_gitService, mod, ct: token);
Assert.False(result.Success);
Assert.Equal("Pull failed for TestMod: fatal: pull failed", result.Error);
}
[Fact]
public async Task UpdateAsync_PullFailureButNoLongerBehind_ReturnsSuccess()
{
CancellationToken token = TestContext.Current.CancellationToken;
InstalledMod mod = MakeMod();
_gitService.FetchAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(new GitOperationResult(true, 0, null));
_gitService.PullAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(new GitOperationResult(false, 1, "fatal: transient failure"));
_gitService.GetCommitDifferenceAsync(mod.Path, token)
.Returns((Behind: 0, Ahead: 0));
var result = await GitModUpdater.UpdateAsync(_gitService, mod, ct: token);
Assert.True(result.Success);
Assert.Null(result.Error);
}
[Fact]
public async Task UpdateAsync_ExitCodeOneFourOne_ReturnsSuccessWhenNoLongerBehind()
{
CancellationToken token = TestContext.Current.CancellationToken;
InstalledMod mod = MakeMod();
_gitService.FetchAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(GitOperationResult.FromExitCode(141, "progress pipe closed"));
_gitService.PullAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(GitOperationResult.FromExitCode(141, "progress pipe closed"));
_gitService.GetCommitDifferenceAsync(mod.Path, token)
.Returns((Behind: 0, Ahead: 0));
var result = await GitModUpdater.UpdateAsync(_gitService, mod, ct: token);
Assert.True(result.Success);
Assert.Null(result.Error);
await _gitService.Received(1).GetCommitDifferenceAsync(mod.Path, token);
}
[Fact]
public async Task UpdateAsync_PullSuccessButStillBehind_ReturnsFailure()
{
CancellationToken token = TestContext.Current.CancellationToken;
InstalledMod mod = MakeMod();
_gitService.FetchAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(new GitOperationResult(true, 0, null));
_gitService.PullAsync(mod.Path, Arg.Any<IProgress<string>>(), token)
.Returns(new GitOperationResult(true, 0, null));
_gitService.GetCommitDifferenceAsync(mod.Path, token)
.Returns((Behind: 2, Ahead: 0));
var result = await GitModUpdater.UpdateAsync(_gitService, mod, ct: token);
Assert.False(result.Success);
Assert.Equal("Pull completed for TestMod, but it is still 2 commit(s) behind", result.Error);
}
}
@@ -0,0 +1,34 @@
namespace FCPModUpdater.Tests.Services;
public class GitOperationResultTests
{
[Fact]
public void FromExitCode_Zero_IsSuccessful()
{
GitOperationResult result = GitOperationResult.FromExitCode(0, null);
Assert.True(result.Success);
Assert.Equal(0, result.ExitCode);
Assert.Null(result.Error);
}
[Fact]
public void FromExitCode_OneFourOne_IsSuccessful()
{
GitOperationResult result = GitOperationResult.FromExitCode(141, "progress pipe closed");
Assert.True(result.Success);
Assert.Equal(141, result.ExitCode);
Assert.Equal("progress pipe closed", result.Error);
}
[Fact]
public void FromExitCode_NonZero_IsFailure()
{
GitOperationResult result = GitOperationResult.FromExitCode(128, "fatal: failed");
Assert.False(result.Success);
Assert.Equal(128, result.ExitCode);
Assert.Equal("fatal: failed", result.Error);
}
}
@@ -159,7 +159,7 @@ public class DiscoverModsAsyncTests : IDisposable
_gitService.HasLocalChangesAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_gitService.FetchAsync(Arg.Any<string>(), Arg.Any<IProgress<string>>(), Arg.Any<CancellationToken>())
.Returns(true);
.Returns(new GitOperationResult(true, 0, null));
_gitService.GetCommitDifferenceAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Behind: 0, Ahead: 0));
@@ -207,7 +207,7 @@ public class DiscoverModsAsyncTests : IDisposable
_gitService.HasLocalChangesAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_gitService.FetchAsync(Arg.Any<string>(), Arg.Any<IProgress<string>>(), Arg.Any<CancellationToken>())
.Returns(true);
.Returns(new GitOperationResult(true, 0, null));
_gitService.GetCommitDifferenceAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Behind: 0, Ahead: 0));
@@ -250,7 +250,7 @@ public class DiscoverModsAsyncTests : IDisposable
_gitService.HasLocalChangesAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_gitService.FetchAsync(Arg.Any<string>(), Arg.Any<IProgress<string>>(), Arg.Any<CancellationToken>())
.Returns(true);
.Returns(new GitOperationResult(true, 0, null));
IReadOnlyList<InstalledMod> result = await _service.DiscoverModsAsync(_tempDir, ct: token);
@@ -297,7 +297,7 @@ public class DiscoverModsAsyncTests : IDisposable
_gitService.HasLocalChangesAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_gitService.FetchAsync(Arg.Any<string>(), Arg.Any<IProgress<string>>(), Arg.Any<CancellationToken>())
.Returns(true);
.Returns(new GitOperationResult(true, 0, null));
_gitService.GetCommitDifferenceAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Behind: 3, Ahead: 0));
+23
View File
@@ -0,0 +1,23 @@
namespace FCPModUpdater.Models;
public sealed record GitOperationResult(
bool Success,
int ExitCode,
string? Error)
{
public static GitOperationResult FromExitCode(int exitCode, string? error)
{
return new GitOperationResult(IsSuccessExitCode(exitCode), exitCode, NormalizeError(error));
}
private static bool IsSuccessExitCode(int exitCode)
{
// Exit code 141 = SIGPIPE (128+13), harmless when git's progress pipe closes early.
return exitCode is 0 or 141;
}
private static string? NormalizeError(string? error)
{
return string.IsNullOrWhiteSpace(error) ? null : error.Trim();
}
}
+59
View File
@@ -0,0 +1,59 @@
using FCPModUpdater.Models;
namespace FCPModUpdater.Services;
public static class GitModUpdater
{
public static async Task<(bool Success, string? Error)> UpdateAsync(
IGitService gitService,
InstalledMod mod,
IProgress<double>? progress = null,
CancellationToken ct = default)
{
progress?.Report(25);
GitOperationResult fetchResult = await gitService.FetchAsync(mod.Path, ct: ct);
if (!fetchResult.Success)
{
return await VerifyOrFailAsync(gitService, mod, "Fetch", fetchResult, ct);
}
progress?.Report(50);
GitOperationResult pullResult = await gitService.PullAsync(mod.Path, ct: ct);
progress?.Report(100);
(int behind, _) = await gitService.GetCommitDifferenceAsync(mod.Path, ct);
if (behind == 0)
{
return (Success: true, Error: null);
}
if (pullResult.Success)
{
return (Success: false,
Error: $"Pull completed for {mod.Name}, but it is still {behind} commit(s) behind");
}
return (Success: false, Error: $"Pull failed for {mod.Name}: {FormatGitFailure(pullResult)}");
}
private static async Task<(bool Success, string? Error)> VerifyOrFailAsync(
IGitService gitService,
InstalledMod mod,
string operation,
GitOperationResult result,
CancellationToken ct)
{
(int behind, _) = await gitService.GetCommitDifferenceAsync(mod.Path, ct);
if (behind == 0)
{
return (Success: true, Error: null);
}
return (Success: false, Error: $"{operation} failed for {mod.Name}: {FormatGitFailure(result)}");
}
private static string FormatGitFailure(GitOperationResult result)
{
return result.Error ?? $"git exited with code {result.ExitCode}";
}
}
+22 -12
View File
@@ -108,7 +108,7 @@ public partial class GitService : IGitService
return ParseCommitLog(output);
}
public async Task<bool> FetchAsync(string path, IProgress<string>? progress = null,
public async Task<GitOperationResult> FetchAsync(string path, IProgress<string>? progress = null,
CancellationToken ct = default)
{
progress?.Report("Fetching from remote...");
@@ -119,13 +119,15 @@ public partial class GitService : IGitService
ct,
idleTimeoutMs: NetworkCommandIdleTimeoutMs);
if (exitCode != 0)
progress?.Report($"Fetch failed: {error}");
var result = GitOperationResult.FromExitCode(exitCode, error);
return exitCode == 0;
if (!result.Success)
progress?.Report($"Fetch failed: {result.Error ?? $"git exited with code {result.ExitCode}"}");
return result;
}
public async Task<bool> PullAsync(string path, IProgress<string>? progress = null,
public async Task<GitOperationResult> PullAsync(string path, IProgress<string>? progress = null,
CancellationToken ct = default)
{
progress?.Report("Pulling changes...");
@@ -136,10 +138,12 @@ public partial class GitService : IGitService
ct,
idleTimeoutMs: NetworkCommandIdleTimeoutMs);
if (exitCode != 0)
progress?.Report($"Pull failed: {error}");
var result = GitOperationResult.FromExitCode(exitCode, error);
return exitCode == 0;
if (!result.Success)
progress?.Report($"Pull failed: {result.Error ?? $"git exited with code {result.ExitCode}"}");
return result;
}
public async Task<(bool Success, string? Error)> CloneAsync(string url, string targetPath, IProgress<string>? progress = null,
@@ -157,10 +161,10 @@ public partial class GitService : IGitService
ct,
idleTimeoutMs: NetworkCommandIdleTimeoutMs);
// Exit code 141 = SIGPIPE (128+13), harmless when git's progress pipe closes early
if (exitCode != 0 && exitCode != 141)
var result = GitOperationResult.FromExitCode(exitCode, error);
if (!result.Success)
{
var errorMessage = $"Clone failed (exit code {exitCode}): url={url}, target={targetPath}, git output: {error}";
var errorMessage = $"Clone failed for {targetPath}: {FormatGitFailure(result)}";
progress?.Report(errorMessage);
return (Success: false, Error: errorMessage);
}
@@ -168,7 +172,8 @@ public partial class GitService : IGitService
// Verify the clone actually produced a valid git repo
if (!Directory.Exists(targetPath) || !await IsGitRepositoryAsync(targetPath, ct))
{
var errorMessage = $"Clone appeared to finish but target is not a valid git repo: url={url}, target={targetPath}, git output: {error}";
var errorMessage =
$"Clone appeared to finish but target is not a valid git repo: url={url}, target={targetPath}, git output: {result.Error}";
progress?.Report(errorMessage);
return (Success: false, Error: errorMessage);
}
@@ -176,6 +181,11 @@ public partial class GitService : IGitService
return (Success: true, Error: null);
}
internal static string FormatGitFailure(GitOperationResult result)
{
return result.Error ?? $"git exited with code {result.ExitCode}";
}
public async Task<IReadOnlyList<string>> GetRemoteBranchesAsync(string path, CancellationToken ct = default)
{
var (exitCode, output, _) = await RunGitCommandAsync(path, "branch -r", ct);
+2 -2
View File
@@ -10,8 +10,8 @@ public interface IGitService
Task<string?> GetCurrentBranchAsync(string path, CancellationToken ct = default);
Task<(int Behind, int Ahead)> GetCommitDifferenceAsync(string path, 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> PullAsync(string path, IProgress<string>? progress = null, CancellationToken ct = default);
Task<GitOperationResult> FetchAsync(string path, IProgress<string>? progress = null, CancellationToken ct = default);
Task<GitOperationResult> PullAsync(string path, IProgress<string>? progress = null, CancellationToken ct = default);
Task<(bool Success, string? Error)> 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);
+1 -13
View File
@@ -156,19 +156,7 @@ public class InteractiveMenu
"Updating mods",
selected.ToList(),
mod => mod.Name,
async (mod, progress) =>
{
progress.Report(25);
var fetchOk = await _gitService.FetchAsync(mod.Path, ct: ct);
if (!fetchOk)
return (Success: false, Error: "Fetch failed");
progress.Report(50);
var pullOk = await _gitService.PullAsync(mod.Path, ct: ct);
progress.Report(100);
return (Success: pullOk, Error: pullOk ? null : "Pull failed");
});
async (mod, progress) => await GitModUpdater.UpdateAsync(_gitService, mod, progress, ct));
ModTableRenderer.RenderUpdateSummary(results);
WaitForKey();