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(); } /// /// Creates a service with default implementations. For simple usage without DI. /// public static async Task 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 Repos => _state.Repos; public IReadOnlyList History => _state.History; public async Task> ScanAndRegisterReposAsync(CancellationToken ct = default) { System.Diagnostics.Debug.WriteLine($"[GitMessageImproverService] Scanning WorkspaceRoot: {_options.WorkspaceRoot}"); var discovered = _gitOps.DiscoverRepositories(_options.WorkspaceRoot); var newRepos = new List(); 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 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 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 GetBranches(string repoPath) { return _gitOps.GetBranches(repoPath); } public Task 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 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> AnalyzeAllReposAsync( bool onlyNeedsImprovement = true, IProgress<(string Repo, int Processed)>? progress = null, CancellationToken ct = default) { var allAnalyses = new List(); 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 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 GenerateSuggestionsAsync( IEnumerable analyses, IProgress? progress = null, CancellationToken ct = default) { var results = await _rewriter.SuggestBatchAsync(analyses, progress, ct); var failures = new List(); 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 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 PreviewChanges(IEnumerable 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 ApplyChangesAsync( IEnumerable operations, bool dryRun = true, IProgress<(int Processed, int Total)>? progress = null, CancellationToken ct = default) { var opList = operations.ToList(); var results = new List(); 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 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 AnalyzeHistoryHealthAsync( string repoPath, HistoryAnalysisOptions? options = null, IProgress? progress = null, CancellationToken ct = default) { var analysis = await _healthAnalyzer.AnalyzeAsync(repoPath, options, progress, ct); return _reportGenerator.GenerateReport(analysis); } public async Task AnalyzeHistoryHealthAsync( ManagedRepo repo, HistoryAnalysisOptions? options = null, IProgress? progress = null, CancellationToken ct = default) { var analysis = await _healthAnalyzer.AnalyzeAsync(repo, options, progress, ct); return _reportGenerator.GenerateReport(analysis); } public Task 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 ExecuteCleanupAsync( ManagedRepo repo, CleanupOperation operation, CleanupExecutionOptions? options = null, IProgress? progress = null, CancellationToken ct = default) { return _cleanupExecutor.ExecuteAsync(repo, operation, options, progress, ct); } public async Task ExecuteAllCleanupsAsync( ManagedRepo repo, CleanupSuggestions suggestions, CleanupExecutionOptions? options = null, IProgress? 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 CreateBackupBranchAsync( ManagedRepo repo, string? branchName = null, CancellationToken ct = default) { return _cleanupExecutor.CreateBackupBranchAsync(repo, branchName, ct); } public RewriteSafetyInfo GetRewriteSafetyInfo(string repoPath, IEnumerable 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 ExecuteBatchRewriteAsync( string repoPath, IEnumerable 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(); 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; } }