760 lines
26 KiB
C#
Executable File
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;
|
|
}
|
|
}
|