Files
gitcommiteditor/Services/GitMessageImproverService.cs

760 lines
26 KiB
C#
Executable File

using System.Text;
using MarketAlly.GitCommitEditor.Models;
using MarketAlly.GitCommitEditor.Models.HistoryHealth;
using MarketAlly.GitCommitEditor.Options;
using MarketAlly.GitCommitEditor.Resources;
using MarketAlly.GitCommitEditor.Rewriters;
namespace MarketAlly.GitCommitEditor.Services;
public sealed class GitMessageImproverService : IGitMessageImproverService
{
private readonly GitImproverOptions _options;
private readonly IGitOperationsService _gitOps;
private readonly ICommitMessageAnalyzer _analyzer;
private readonly ICommitMessageRewriter _rewriter;
private readonly IStateRepository _stateRepo;
private readonly IHistoryHealthAnalyzer _healthAnalyzer;
private readonly IHealthReportGenerator _reportGenerator;
private readonly ICleanupExecutor _cleanupExecutor;
private ImproverState _state;
private bool _disposed;
public GitMessageImproverService(
GitImproverOptions options,
IGitOperationsService gitOps,
ICommitMessageAnalyzer analyzer,
ICommitMessageRewriter rewriter,
IStateRepository stateRepo,
IHistoryHealthAnalyzer? healthAnalyzer = null,
IHealthReportGenerator? reportGenerator = null,
ICleanupExecutor? cleanupExecutor = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(gitOps);
ArgumentNullException.ThrowIfNull(analyzer);
ArgumentNullException.ThrowIfNull(rewriter);
ArgumentNullException.ThrowIfNull(stateRepo);
_options = options;
_gitOps = gitOps;
_analyzer = analyzer;
_rewriter = rewriter;
_stateRepo = stateRepo;
// Create default health analysis components if not provided
var commitAnalyzer = new CommitAnalyzer(options.Rules);
_healthAnalyzer = healthAnalyzer ?? new HistoryHealthAnalyzer(commitAnalyzer);
_reportGenerator = reportGenerator ?? new HealthReportGenerator();
_cleanupExecutor = cleanupExecutor ?? new CleanupExecutor(gitOps, analyzer, rewriter);
_state = new ImproverState();
}
/// <summary>
/// Creates a service with default implementations. For simple usage without DI.
/// </summary>
public static async Task<GitMessageImproverService> CreateAsync(GitImproverOptions options)
{
options.ValidateAndThrow();
var gitOps = new GitOperationsService();
var analyzer = new CommitMessageAnalyzer(options.Rules);
var stateRepo = new FileStateRepository(options.StateFilePath);
// Use DynamicCommitRewriter which handles API key changes at runtime
ICommitMessageRewriter rewriter = new DynamicCommitRewriter(options.Ai);
var service = new GitMessageImproverService(options, gitOps, analyzer, rewriter, stateRepo);
await service.LoadStateAsync();
return service;
}
public async Task LoadStateAsync(CancellationToken ct = default)
{
_state = await _stateRepo.LoadAsync(ct);
// Prune old history entries to prevent unbounded growth
var pruned = _state.PruneHistory();
var orphaned = _state.RemoveOrphanedHistory();
if (pruned > 0 || orphaned > 0)
{
await _stateRepo.SaveAsync(_state, ct);
}
}
public IReadOnlyList<ManagedRepo> Repos => _state.Repos;
public IReadOnlyList<RewriteOperation> History => _state.History;
public async Task<IReadOnlyList<ManagedRepo>> ScanAndRegisterReposAsync(CancellationToken ct = default)
{
System.Diagnostics.Debug.WriteLine($"[GitMessageImproverService] Scanning WorkspaceRoot: {_options.WorkspaceRoot}");
var discovered = _gitOps.DiscoverRepositories(_options.WorkspaceRoot);
var newRepos = new List<ManagedRepo>();
foreach (var repoPath in discovered)
{
if (ct.IsCancellationRequested) break;
if (_state.Repos.Any(r => r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase)))
continue;
try
{
var managed = _gitOps.CreateManagedRepo(repoPath);
_state.Repos.Add(managed);
newRepos.Add(managed);
}
catch
{
// Skip repos that can't be opened
}
}
await _stateRepo.SaveAsync(_state, ct);
return newRepos;
}
public async Task<ManagedRepo> RegisterRepoAsync(string repoPath)
{
var existing = _state.Repos.FirstOrDefault(r =>
r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase));
if (existing != null)
return existing;
var managed = _gitOps.CreateManagedRepo(repoPath);
_state.Repos.Add(managed);
await _stateRepo.SaveAsync(_state);
return managed;
}
public async Task<bool> UnregisterRepoAsync(string repoIdOrPath)
{
var repo = _state.Repos.FirstOrDefault(r =>
r.Id == repoIdOrPath ||
r.Path.Equals(repoIdOrPath, StringComparison.OrdinalIgnoreCase));
if (repo == null) return false;
_state.Repos.Remove(repo);
await _stateRepo.SaveAsync(_state);
return true;
}
public IEnumerable<BranchInfo> GetBranches(string repoPath)
{
return _gitOps.GetBranches(repoPath);
}
public Task<bool> CheckoutBranchAsync(ManagedRepo repo, string branchName)
{
return Task.Run(() =>
{
try
{
using var repository = new LibGit2Sharp.Repository(repo.Path);
// Find the branch
var branch = repository.Branches[branchName]
?? repository.Branches[$"refs/heads/{branchName}"];
if (branch == null)
{
return false;
}
// Check for uncommitted changes
var status = repository.RetrieveStatus(new LibGit2Sharp.StatusOptions());
if (status.IsDirty)
{
return false;
}
// Checkout the branch
LibGit2Sharp.Commands.Checkout(repository, branch);
return true;
}
catch
{
return false;
}
});
}
public IEnumerable<BackupBranchInfo> GetBackupBranches(string repoPath)
{
return _gitOps.GetBackupBranches(repoPath);
}
public bool DeleteBranch(string repoPath, string branchName)
{
return _gitOps.DeleteBranch(repoPath, branchName);
}
public int DeleteAllBackupBranches(string repoPath)
{
var backupBranches = _gitOps.GetBackupBranches(repoPath).ToList();
var deletedCount = 0;
foreach (var branch in backupBranches)
{
if (_gitOps.DeleteBranch(repoPath, branch.Name))
{
deletedCount++;
}
}
return deletedCount;
}
public async Task<IReadOnlyList<CommitAnalysis>> AnalyzeAllReposAsync(
bool onlyNeedsImprovement = true,
IProgress<(string Repo, int Processed)>? progress = null,
CancellationToken ct = default)
{
var allAnalyses = new List<CommitAnalysis>();
foreach (var repo in _state.Repos)
{
if (ct.IsCancellationRequested) break;
var analyses = AnalyzeRepo(repo);
if (onlyNeedsImprovement)
{
analyses = analyses.Where(a => a.Quality.NeedsImprovement);
}
var list = analyses.ToList();
allAnalyses.AddRange(list);
repo.LastScannedAt = DateTimeOffset.UtcNow;
repo.LastAnalyzedAt = DateTimeOffset.UtcNow;
repo.CommitsNeedingImprovement = list.Count(a => a.Quality.NeedsImprovement);
progress?.Report((repo.Name, list.Count));
}
await _stateRepo.SaveAsync(_state, ct);
return allAnalyses;
}
public IEnumerable<CommitAnalysis> AnalyzeRepo(ManagedRepo repo)
{
return _gitOps.AnalyzeCommits(
repo,
_analyzer,
_options.MaxCommitsPerRepo,
_options.AnalyzeSince,
_options.ExcludedAuthors);
}
public CommitAnalysis AnalyzeCommit(string repoPath, string commitHash)
{
var repo = _state.Repos.FirstOrDefault(r =>
r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase))
?? _gitOps.CreateManagedRepo(repoPath);
return _gitOps.AnalyzeCommits(repo, _analyzer, 1000)
.First(c => c.CommitHash.StartsWith(commitHash, StringComparison.OrdinalIgnoreCase));
}
public async Task UpdateRepoAnalysisAsync(ManagedRepo repo, int totalCommits, int commitsNeedingImprovement, CancellationToken ct = default)
{
repo.LastAnalyzedAt = DateTimeOffset.UtcNow;
repo.LastScannedAt = DateTimeOffset.UtcNow;
repo.TotalCommits = totalCommits;
repo.CommitsNeedingImprovement = commitsNeedingImprovement;
await _stateRepo.SaveAsync(_state, ct);
}
public async Task<BatchSuggestionResult> GenerateSuggestionsAsync(
IEnumerable<CommitAnalysis> analyses,
IProgress<int>? progress = null,
CancellationToken ct = default)
{
var results = await _rewriter.SuggestBatchAsync(analyses, progress, ct);
var failures = new List<SuggestionFailure>();
var successCount = 0;
foreach (var (analysis, result) in results)
{
analysis.Status = AnalysisStatus.Analyzed;
if (result.Success && !string.IsNullOrEmpty(result.Suggestion))
{
analysis.SuggestedMessage = result.Suggestion;
successCount++;
}
else
{
// Don't set SuggestedMessage for failures - leave it empty
failures.Add(new SuggestionFailure
{
CommitHash = analysis.ShortHash,
OriginalMessage = analysis.OriginalMessage,
Reason = result.ErrorMessage ?? Str.Service_UnknownError,
RawResponse = result.RawResponse
});
}
}
return new BatchSuggestionResult
{
Analyses = results.Select(r => r.Analysis).ToList(),
SuccessCount = successCount,
FailedCount = failures.Count,
Failures = failures
};
}
public async Task<SuggestionResult> GenerateSuggestionAsync(CommitAnalysis analysis, CancellationToken ct = default)
{
var result = await _rewriter.SuggestImprovedMessageAsync(analysis, ct);
analysis.Status = AnalysisStatus.Analyzed;
if (result.Success && !string.IsNullOrEmpty(result.Suggestion))
{
analysis.SuggestedMessage = result.Suggestion;
}
return result;
}
public IReadOnlyList<RewriteOperation> PreviewChanges(IEnumerable<CommitAnalysis> analyses)
{
return analyses
.Where(a => !string.IsNullOrEmpty(a.SuggestedMessage))
.Select(a => new RewriteOperation
{
RepoId = a.RepoId,
RepoPath = a.RepoPath,
CommitHash = a.CommitHash,
OriginalMessage = a.OriginalMessage,
NewMessage = a.SuggestedMessage!,
IsLatestCommit = a.IsLatestCommit,
Status = OperationStatus.Pending
})
.ToList();
}
public async Task<BatchResult> ApplyChangesAsync(
IEnumerable<RewriteOperation> operations,
bool dryRun = true,
IProgress<(int Processed, int Total)>? progress = null,
CancellationToken ct = default)
{
var opList = operations.ToList();
var results = new List<RewriteOperation>();
var processed = 0;
var successful = 0;
var failed = 0;
var skipped = 0;
var byRepo = opList.GroupBy(o => o.RepoPath);
foreach (var repoGroup in byRepo)
{
if (ct.IsCancellationRequested) break;
var repo = _state.Repos.FirstOrDefault(r => r.Path == repoGroup.Key);
if (repo == null)
{
foreach (var op in repoGroup)
{
op.Status = OperationStatus.Failed;
op.ErrorMessage = Str.Service_RepoNotRegistered;
results.Add(op);
failed++;
processed++;
}
continue;
}
var orderedOps = repoGroup.OrderBy(o => o.IsLatestCommit ? 1 : 0).ToList();
foreach (var op in orderedOps)
{
if (ct.IsCancellationRequested)
{
op.Status = OperationStatus.Pending;
skipped++;
results.Add(op);
continue;
}
if (dryRun)
{
op.Status = OperationStatus.Pending;
results.Add(op);
skipped++;
}
else
{
var result = op.IsLatestCommit
? _gitOps.AmendLatestCommit(repo, op.NewMessage)
: _gitOps.RewordOlderCommit(repo, op.CommitHash, op.NewMessage);
op.NewCommitHash = result.NewCommitHash;
op.Status = result.Status;
op.ErrorMessage = result.ErrorMessage;
op.AppliedAt = result.AppliedAt;
results.Add(op);
_state.History.Add(op);
if (result.Status == OperationStatus.Applied)
successful++;
else
failed++;
}
processed++;
progress?.Report((processed, opList.Count));
}
}
await _stateRepo.SaveAsync(_state, ct);
return new BatchResult
{
TotalProcessed = processed,
Successful = successful,
Failed = failed,
Skipped = skipped,
Operations = results
};
}
public async Task<RewriteOperation> ApplyChangeAsync(CommitAnalysis analysis, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(analysis.SuggestedMessage))
throw new InvalidOperationException(Str.Service_NoSuggestion);
var repo = _state.Repos.FirstOrDefault(r => r.Id == analysis.RepoId)
?? throw new InvalidOperationException(Str.Service_RepoNotFound(analysis.RepoId));
var operation = analysis.IsLatestCommit
? _gitOps.AmendLatestCommit(repo, analysis.SuggestedMessage)
: _gitOps.RewordOlderCommit(repo, analysis.CommitHash, analysis.SuggestedMessage);
_state.History.Add(operation);
analysis.Status = operation.Status == OperationStatus.Applied
? AnalysisStatus.Applied
: AnalysisStatus.Failed;
await _stateRepo.SaveAsync(_state, ct);
return operation;
}
public bool UndoCommitAmend(string repoPath, string originalCommitHash)
{
return _gitOps.UndoCommitAmend(repoPath, originalCommitHash);
}
public bool IsCommitPushed(string repoPath, string commitHash)
{
return _gitOps.IsCommitPushed(repoPath, commitHash);
}
public TrackingInfo GetTrackingInfo(string repoPath)
{
return _gitOps.GetTrackingInfo(repoPath);
}
public GitPushResult ForcePush(string repoPath)
{
return _gitOps.ForcePush(repoPath);
}
public GitPushResult Push(string repoPath)
{
return _gitOps.Push(repoPath);
}
public string GenerateSummaryReport()
{
var sb = new StringBuilder();
sb.AppendLine("═══════════════════════════════════════════════════════════════");
sb.AppendLine("GIT MESSAGE IMPROVER - SUMMARY REPORT");
sb.AppendLine($"Generated: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine("═══════════════════════════════════════════════════════════════");
sb.AppendLine();
sb.AppendLine($"Total Repositories: {_state.Repos.Count}");
sb.AppendLine($"Total Operations in History: {_state.History.Count}");
sb.AppendLine();
foreach (var repo in _state.Repos.OrderBy(r => r.Name))
{
sb.AppendLine($"┌─ {repo.Name}");
sb.AppendLine($"│ Path: {repo.Path}");
sb.AppendLine($"│ Branch: {repo.CurrentBranch}");
sb.AppendLine($"│ Last Scanned: {repo.LastScannedAt?.ToString("yyyy-MM-dd HH:mm") ?? "Never"}");
sb.AppendLine($"│ Commits Needing Improvement: {repo.CommitsNeedingImprovement}");
sb.AppendLine($"└────────────────────────────────────────");
sb.AppendLine();
}
var recentOps = _state.History
.OrderByDescending(h => h.CreatedAt)
.Take(10);
if (recentOps.Any())
{
sb.AppendLine("RECENT OPERATIONS:");
sb.AppendLine("─────────────────────────────────────────");
foreach (var op in recentOps)
{
var status = op.Status switch
{
OperationStatus.Applied => "✓",
OperationStatus.Failed => "✗",
_ => "○"
};
sb.AppendLine($"{status} [{op.CreatedAt:MM-dd HH:mm}] {op.CommitHash[..7]}: {op.Status}");
}
}
return sb.ToString();
}
// IHistoryHealthService implementation
public async Task<HistoryHealthReport> AnalyzeHistoryHealthAsync(
string repoPath,
HistoryAnalysisOptions? options = null,
IProgress<AnalysisProgress>? progress = null,
CancellationToken ct = default)
{
var analysis = await _healthAnalyzer.AnalyzeAsync(repoPath, options, progress, ct);
return _reportGenerator.GenerateReport(analysis);
}
public async Task<HistoryHealthReport> AnalyzeHistoryHealthAsync(
ManagedRepo repo,
HistoryAnalysisOptions? options = null,
IProgress<AnalysisProgress>? progress = null,
CancellationToken ct = default)
{
var analysis = await _healthAnalyzer.AnalyzeAsync(repo, options, progress, ct);
return _reportGenerator.GenerateReport(analysis);
}
public Task<string> ExportHealthReportAsync(
HistoryHealthReport report,
ReportFormat format,
CancellationToken ct = default)
{
return _reportGenerator.ExportReportAsync(report, format, ct);
}
public Task ExportHealthReportToFileAsync(
HistoryHealthReport report,
ReportFormat format,
string outputPath,
CancellationToken ct = default)
{
return _reportGenerator.ExportReportToFileAsync(report, format, outputPath, ct);
}
public Task<CleanupExecutionResult> ExecuteCleanupAsync(
ManagedRepo repo,
CleanupOperation operation,
CleanupExecutionOptions? options = null,
IProgress<CleanupProgress>? progress = null,
CancellationToken ct = default)
{
return _cleanupExecutor.ExecuteAsync(repo, operation, options, progress, ct);
}
public async Task<BatchCleanupResult> ExecuteAllCleanupsAsync(
ManagedRepo repo,
CleanupSuggestions suggestions,
CleanupExecutionOptions? options = null,
IProgress<CleanupProgress>? progress = null,
CancellationToken ct = default)
{
// Execute operations in order: automated first, then semi-automated
// Skip manual operations as they require human intervention
var operations = suggestions.AutomatedOperations
.Concat(suggestions.SemiAutomatedOperations)
.ToList();
return await _cleanupExecutor.ExecuteBatchAsync(repo, operations, options, progress, ct);
}
public Task<string> CreateBackupBranchAsync(
ManagedRepo repo,
string? branchName = null,
CancellationToken ct = default)
{
return _cleanupExecutor.CreateBackupBranchAsync(repo, branchName, ct);
}
public RewriteSafetyInfo GetRewriteSafetyInfo(string repoPath, IEnumerable<CommitAnalysis> commits)
{
var commitList = commits.ToList();
if (!commitList.Any())
{
return new RewriteSafetyInfo
{
TotalCommitCount = 0,
LocalOnlyCommitCount = 0,
PushedCommitCount = 0
};
}
// Check for uncommitted changes
bool hasUncommittedChanges;
using (var repo = new LibGit2Sharp.Repository(repoPath))
{
var status = repo.RetrieveStatus(new LibGit2Sharp.StatusOptions());
hasUncommittedChanges = status.IsDirty;
}
// Get tracking info
var trackingInfo = _gitOps.GetTrackingInfo(repoPath);
// Check which commits have been pushed
var pushedCount = 0;
var localCount = 0;
foreach (var commit in commitList)
{
if (_gitOps.IsCommitPushed(repoPath, commit.CommitHash))
{
pushedCount++;
}
else
{
localCount++;
}
}
return new RewriteSafetyInfo
{
HasUncommittedChanges = hasUncommittedChanges,
HasPushedCommits = pushedCount > 0,
PushedCommitCount = pushedCount,
LocalOnlyCommitCount = localCount,
TotalCommitCount = commitList.Count,
HasRemoteTracking = trackingInfo.HasUpstream,
RemoteTrackingBranch = trackingInfo.UpstreamBranch,
AheadOfRemote = trackingInfo.AheadBy,
BehindRemote = trackingInfo.BehindBy
};
}
public async Task<BatchRewriteResult> ExecuteBatchRewriteAsync(
string repoPath,
IEnumerable<CommitAnalysis> commits,
bool createBackup = true,
IProgress<(int Current, int Total, string CommitHash)>? progress = null,
CancellationToken ct = default)
{
var commitList = commits
.Where(c => !string.IsNullOrEmpty(c.SuggestedMessage))
.ToList();
if (!commitList.Any())
{
return BatchRewriteResult.Failure("No commits with suggestions to apply.");
}
// Get safety info
var safetyInfo = GetRewriteSafetyInfo(repoPath, commitList);
// Block if uncommitted changes
if (safetyInfo.HasUncommittedChanges)
{
return BatchRewriteResult.Failure(Str.Service_UncommittedChanges);
}
// Get the managed repo
var repo = _state.Repos.FirstOrDefault(r => r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase));
if (repo == null)
{
return BatchRewriteResult.Failure(Str.Service_RepoNotRegisteredPath(repoPath));
}
string? backupBranch = null;
// Create backup branch if requested
if (createBackup)
{
try
{
backupBranch = await CreateBackupBranchAsync(repo, null, ct);
}
catch (Exception ex)
{
return BatchRewriteResult.Failure($"Failed to create backup branch: {ex.Message}");
}
}
var operations = new List<RewriteOperation>();
var successCount = 0;
var failedCount = 0;
var total = commitList.Count;
// Process ALL commits (including HEAD) in a single batch operation
// This ensures consistent rewriting without stale references
System.Diagnostics.Debug.WriteLine($"[BatchRewrite] Processing {commitList.Count} commits in single batch");
var rewrites = commitList.ToDictionary(c => c.CommitHash, c => c.SuggestedMessage!);
var batchOperations = _gitOps.RewordMultipleCommits(repo, rewrites);
foreach (var operation in batchOperations)
{
var commit = commitList.First(c => c.CommitHash == operation.CommitHash);
progress?.Report((operations.Count + 1, total, commit.CommitHash[..7]));
System.Diagnostics.Debug.WriteLine($"[BatchRewrite] Commit {commit.CommitHash[..7]}: Status={operation.Status}, NewHash={operation.NewCommitHash?[..7] ?? "null"}");
operations.Add(operation);
_state.History.Add(operation);
if (operation.Status == OperationStatus.Applied)
{
successCount++;
commit.Status = AnalysisStatus.Applied;
}
else
{
failedCount++;
commit.Status = AnalysisStatus.Failed;
System.Diagnostics.Debug.WriteLine($"[BatchRewrite] FAILED: {operation.ErrorMessage}");
}
}
// Invalidate the repository cache to ensure subsequent analysis sees the new commits
_gitOps.InvalidateCache(repoPath);
System.Diagnostics.Debug.WriteLine($"[BatchRewrite] Cache invalidated for {repoPath}");
await _stateRepo.SaveAsync(_state, ct);
return new BatchRewriteResult
{
Success = successCount > 0,
SuccessCount = successCount,
FailedCount = failedCount,
SkippedCount = total - successCount - failedCount,
RequiresForcePush = safetyInfo.HasPushedCommits && successCount > 0,
BackupBranchName = backupBranch,
Operations = operations
};
}
public void Dispose()
{
if (_disposed) return;
_gitOps.Dispose();
(_rewriter as IDisposable)?.Dispose();
_disposed = true;
}
}