using System.Diagnostics; using MarketAlly.GitCommitEditor.Models; using MarketAlly.GitCommitEditor.Models.HistoryHealth; using MarketAlly.GitCommitEditor.Resources; using MarketAlly.GitCommitEditor.Rewriters; using MarketAlly.LibGit2Sharp; namespace MarketAlly.GitCommitEditor.Services; /// /// Executes cleanup operations on git repositories. /// public sealed class CleanupExecutor : ICleanupExecutor { private readonly IGitOperationsService _gitOps; private readonly ICommitMessageAnalyzer _analyzer; private readonly ICommitMessageRewriter? _rewriter; public CleanupExecutor( IGitOperationsService gitOps, ICommitMessageAnalyzer analyzer, ICommitMessageRewriter? rewriter = null) { _gitOps = gitOps; _analyzer = analyzer; _rewriter = rewriter; } public async Task ExecuteAsync( ManagedRepo repo, CleanupOperation operation, CleanupExecutionOptions? options = null, IProgress? progress = null, CancellationToken ct = default) { options ??= new CleanupExecutionOptions(); var stopwatch = Stopwatch.StartNew(); string? backupBranch = null; try { // Create backup if requested if (options.CreateBackup) { backupBranch = await CreateBackupBranchAsync(repo, options.BackupBranchName, ct); } progress?.Report(new CleanupProgress { CurrentOperation = operation.Title, CurrentIndex = 1, TotalOperations = 1, PercentComplete = 10 }); // Check if commits are pushed if (!options.AllowPushedCommits && operation.AffectedCommits.Count > 0) { var anyPushed = operation.AffectedCommits.Any(hash => _gitOps.IsCommitPushed(repo.Path, hash)); if (anyPushed) { return new CleanupExecutionResult { OperationId = operation.Id, Type = operation.Type, Success = false, ErrorMessage = Str.Cleanup_PushedCommitsBlocked, BackupBranch = backupBranch, Duration = stopwatch.Elapsed }; } } // Execute based on operation type var result = operation.Type switch { CleanupType.RewordMessages => await ExecuteRewordAsync(repo, operation, options, progress, ct), CleanupType.SquashDuplicates => await ExecuteSquashDuplicatesAsync(repo, operation, progress, ct), CleanupType.SquashMerges => await ExecuteSquashMergesAsync(repo, operation, progress, ct), CleanupType.FixAuthorship => await ExecuteFixAuthorshipAsync(repo, operation, progress, ct), CleanupType.ConsolidateMerges => await ExecuteConsolidateMergesAsync(repo, operation, progress, ct), CleanupType.ArchiveBranches => await ExecuteArchiveBranchesAsync(repo, operation, progress, ct), CleanupType.RebaseLinearize => await ExecuteRebaseLinearizeAsync(repo, operation, progress, ct), _ => new CleanupExecutionResult { OperationId = operation.Id, Type = operation.Type, Success = false, ErrorMessage = Str.Cleanup_NotImplemented(operation.Type.ToString()), Duration = stopwatch.Elapsed } }; stopwatch.Stop(); return result with { BackupBranch = backupBranch, Duration = stopwatch.Elapsed, RequiresForcePush = result.CommitsModified > 0 || result.CommitsRemoved > 0 }; } catch (Exception ex) { stopwatch.Stop(); return new CleanupExecutionResult { OperationId = operation.Id, Type = operation.Type, Success = false, ErrorMessage = ex.Message, BackupBranch = backupBranch, Duration = stopwatch.Elapsed }; } } public async Task ExecuteBatchAsync( ManagedRepo repo, IEnumerable operations, CleanupExecutionOptions? options = null, IProgress? progress = null, CancellationToken ct = default) { options ??= new CleanupExecutionOptions(); var opList = operations.ToList(); var results = new List(); var stopwatch = Stopwatch.StartNew(); string? backupBranch = null; // Create single backup for all operations if (options.CreateBackup) { backupBranch = await CreateBackupBranchAsync(repo, options.BackupBranchName, ct); } // Don't create backup for individual operations since we have one for the batch var individualOptions = options with { CreateBackup = false }; for (int i = 0; i < opList.Count; i++) { if (ct.IsCancellationRequested) break; var operation = opList[i]; progress?.Report(new CleanupProgress { CurrentOperation = operation.Title, CurrentIndex = i + 1, TotalOperations = opList.Count, PercentComplete = (i * 100) / opList.Count }); var result = await ExecuteAsync(repo, operation, individualOptions, null, ct); results.Add(result); // Stop on critical failure if (!result.Success && operation.Risk >= RiskLevel.High) { break; } } stopwatch.Stop(); return new BatchCleanupResult { TotalOperations = opList.Count, Successful = results.Count(r => r.Success), Failed = results.Count(r => !r.Success), Skipped = opList.Count - results.Count, BackupBranch = backupBranch, RequiresForcePush = results.Any(r => r.RequiresForcePush), Results = results, TotalDuration = stopwatch.Elapsed }; } public Task PreviewAsync( ManagedRepo repo, CleanupOperation operation, CancellationToken ct = default) { return Task.FromResult(new CleanupPreview { CommitsAffected = operation.AffectedCommits.Count, RefsAffected = 1, CommitsToModify = operation.AffectedCommits, CommitsToRemove = operation.Type == CleanupType.SquashDuplicates ? operation.AffectedCommits.Skip(1).ToList() : [], ExpectedScoreImprovement = operation.ExpectedScoreImprovement, Summary = GeneratePreviewSummary(operation) }); } public Task CreateBackupBranchAsync( ManagedRepo repo, string? branchName = null, CancellationToken ct = default) { branchName ??= $"backup/pre-cleanup-{DateTime.Now:yyyyMMdd-HHmmss}"; using var repository = new Repository(repo.Path); var branch = repository.CreateBranch(branchName); return Task.FromResult(branch.FriendlyName); } private async Task ExecuteRewordAsync( ManagedRepo repo, CleanupOperation operation, CleanupExecutionOptions options, IProgress? progress, CancellationToken ct) { var modifiedHashes = new List(); var newHashes = new List(); foreach (var commitHash in operation.AffectedCommits) { ct.ThrowIfCancellationRequested(); try { // Get the commit using var repository = new Repository(repo.Path); var commit = repository.Lookup(commitHash); if (commit == null) continue; // Analyze current message var analysis = _analyzer.Analyze(commit.Message); if (!analysis.NeedsImprovement) continue; string newMessage; // Generate improved message if (options.UseAiForMessages && _rewriter != null) { var commitAnalysis = new CommitAnalysis { RepoId = repo.Id, RepoName = repo.Name, RepoPath = repo.Path, CommitHash = commitHash, OriginalMessage = commit.Message, Author = commit.Author.Name, AuthorEmail = commit.Author.Email, CommitDate = commit.Author.When, Quality = analysis, IsLatestCommit = repository.Head.Tip.Sha == commitHash }; var suggestionResult = await _rewriter.SuggestImprovedMessageAsync(commitAnalysis, ct); if (suggestionResult.Success && !string.IsNullOrEmpty(suggestionResult.Suggestion)) { newMessage = suggestionResult.Suggestion; } else { // AI failed, fall back to basic cleanup newMessage = CleanupMessage(commit.Message); } } else { // Basic cleanup without AI newMessage = CleanupMessage(commit.Message); } // Apply the reword var isLatest = repository.Head.Tip.Sha == commitHash; var result = isLatest ? _gitOps.AmendLatestCommit(repo, newMessage) : _gitOps.RewordOlderCommit(repo, commitHash, newMessage); if (result.Status == OperationStatus.Applied) { modifiedHashes.Add(commitHash); if (!string.IsNullOrEmpty(result.NewCommitHash)) { newHashes.Add(result.NewCommitHash); } } } catch { // Continue with next commit } } return new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.RewordMessages, Success = modifiedHashes.Count > 0, CommitsModified = modifiedHashes.Count, ModifiedCommitHashes = modifiedHashes, NewCommitHashes = newHashes }; } private Task ExecuteSquashDuplicatesAsync( ManagedRepo repo, CleanupOperation operation, IProgress? progress, CancellationToken ct) { if (operation.AffectedCommits.Count < 2) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashDuplicates, Success = false, ErrorMessage = Str.Cleanup_NeedTwoCommits }); } try { // The first commit in AffectedCommits is kept, the rest are dropped var hashesToDrop = operation.AffectedCommits.Skip(1).ToHashSet(StringComparer.OrdinalIgnoreCase); if (hashesToDrop.Count == 0) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashDuplicates, Success = true, CommitsRemoved = 0 }); } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_Rebuilding, PercentComplete = 10 }); using var repository = new Repository(repo.Path); // Get the current branch var currentBranch = repository.Head; if (currentBranch.Tip == null) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashDuplicates, Success = false, ErrorMessage = Str.Cleanup_NoCommitsOnBranch }); } // Collect all commits from HEAD to root in reverse order (oldest first) var allCommits = new List(); var commit = currentBranch.Tip; while (commit != null) { allCommits.Add(commit); commit = commit.Parents.FirstOrDefault(); } allCommits.Reverse(); // Now oldest is first // Filter out the commits we want to drop var commitsToKeep = allCommits .Where(c => !hashesToDrop.Contains(c.Sha)) .ToList(); if (commitsToKeep.Count == allCommits.Count) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashDuplicates, Success = true, CommitsRemoved = 0, ErrorMessage = Str.Cleanup_NoMatchingCommits }); } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_RebuildingCount(commitsToKeep.Count), PercentComplete = 30 }); // Rebuild the commit chain Commit? newParent = null; var newCommits = new List(); for (int i = 0; i < commitsToKeep.Count; i++) { ct.ThrowIfCancellationRequested(); var originalCommit = commitsToKeep[i]; progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, commitsToKeep.Count), PercentComplete = 30 + (i * 60 / commitsToKeep.Count) }); // Create the new commit with the same content but new parent chain var parents = newParent != null ? new[] { newParent } : Array.Empty(); var newCommit = repository.ObjectDatabase.CreateCommit( originalCommit.Author, originalCommit.Committer, originalCommit.Message, originalCommit.Tree, parents, prettifyMessage: false); newCommits.Add(newCommit); newParent = newCommit; } // Update the branch to point to the new tip if (newParent != null) { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_UpdatingBranch, PercentComplete = 95 }); // Get the branch name before any updates var branchName = currentBranch.FriendlyName; var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; if (!isDetached && !string.IsNullOrEmpty(branchName)) { // We're on a branch - update the branch ref and keep HEAD attached var branchRef = repository.Refs[$"refs/heads/{branchName}"]; if (branchRef != null) { // Update the branch to point to new commit repository.Refs.UpdateTarget(branchRef, newParent.Id, $"cleanup: dropped {hashesToDrop.Count} duplicate commits"); // Checkout the branch to update HEAD and working directory Commands.Checkout(repository, repository.Branches[branchName], new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } } else { // Detached HEAD - just reset repository.Reset(ResetMode.Hard, newParent); } } return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashDuplicates, Success = true, CommitsRemoved = hashesToDrop.Count, CommitsModified = commitsToKeep.Count, NewCommitHashes = newCommits.Select(c => c.Sha).ToList() }); } catch (Exception ex) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashDuplicates, Success = false, ErrorMessage = Str.Cleanup_DropDuplicatesFailed(ex.Message) }); } } private Task ExecuteSquashMergesAsync( ManagedRepo repo, CleanupOperation operation, IProgress? progress, CancellationToken ct) { // Squash merge commits by dropping them and keeping the changes if (operation.AffectedCommits.Count == 0) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashMerges, Success = false, ErrorMessage = Str.Cleanup_NoCommitsToSquash }); } try { var hashesToDrop = operation.AffectedCommits.ToHashSet(StringComparer.OrdinalIgnoreCase); progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_SquashingMerges, PercentComplete = 10 }); using var repository = new Repository(repo.Path); var currentBranch = repository.Head; if (currentBranch.Tip == null) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashMerges, Success = false, ErrorMessage = Str.Cleanup_NoCommitsOnBranch }); } // Collect all commits from HEAD to root (oldest first) var allCommits = new List(); var commit = currentBranch.Tip; while (commit != null) { allCommits.Add(commit); commit = commit.Parents.FirstOrDefault(); } allCommits.Reverse(); // Filter out the merge commits we want to drop var commitsToKeep = allCommits .Where(c => !hashesToDrop.Contains(c.Sha)) .ToList(); if (commitsToKeep.Count == allCommits.Count) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashMerges, Success = true, CommitsRemoved = 0, ErrorMessage = Str.Cleanup_NoMergeCommits }); } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_RebuildingCount(commitsToKeep.Count), PercentComplete = 30 }); // Rebuild the commit chain without the merge commits Commit? newParent = null; var newCommits = new List(); for (int i = 0; i < commitsToKeep.Count; i++) { ct.ThrowIfCancellationRequested(); var originalCommit = commitsToKeep[i]; progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, commitsToKeep.Count), PercentComplete = 30 + (i * 60 / commitsToKeep.Count) }); var parents = newParent != null ? new[] { newParent } : Array.Empty(); var newCommit = repository.ObjectDatabase.CreateCommit( originalCommit.Author, originalCommit.Committer, originalCommit.Message, originalCommit.Tree, parents, prettifyMessage: false); newCommits.Add(newCommit); newParent = newCommit; } // Update the branch to point to the new tip if (newParent != null) { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_UpdatingBranch, PercentComplete = 95 }); var branchName = currentBranch.FriendlyName; var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; if (!isDetached && !string.IsNullOrEmpty(branchName)) { var branchRef = repository.Refs[$"refs/heads/{branchName}"]; if (branchRef != null) { repository.Refs.UpdateTarget(branchRef, newParent.Id, $"cleanup: squashed {hashesToDrop.Count} merge commits"); Commands.Checkout(repository, repository.Branches[branchName], new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } } else { repository.Reset(ResetMode.Hard, newParent); } } return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashMerges, Success = true, CommitsRemoved = hashesToDrop.Count, CommitsModified = commitsToKeep.Count, NewCommitHashes = newCommits.Select(c => c.Sha).ToList() }); } catch (Exception ex) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.SquashMerges, Success = false, ErrorMessage = Str.Cleanup_SquashMergeFailed(ex.Message) }); } } private Task ExecuteFixAuthorshipAsync( ManagedRepo repo, CleanupOperation operation, IProgress? progress, CancellationToken ct) { // Fix authorship by rewriting commits with corrected author info if (operation.AffectedCommits.Count == 0) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.FixAuthorship, Success = false, ErrorMessage = Str.Cleanup_NoCommitsToFix }); } try { var hashesToFix = operation.AffectedCommits.ToHashSet(StringComparer.OrdinalIgnoreCase); progress?.Report(new CleanupProgress { CurrentOperation = "Fixing commit authorship...", PercentComplete = 10 }); using var repository = new Repository(repo.Path); var currentBranch = repository.Head; if (currentBranch.Tip == null) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.FixAuthorship, Success = false, ErrorMessage = Str.Cleanup_NoCommitsOnBranch }); } // Get the configured user for authorship fixes var config = repository.Config; var userName = config.Get("user.name")?.Value ?? "Unknown"; var userEmail = config.Get("user.email")?.Value ?? "unknown@unknown.com"; var newSignature = new Signature(userName, userEmail, DateTimeOffset.Now); // Collect all commits from HEAD to root (oldest first) var allCommits = new List(); var commit = currentBranch.Tip; while (commit != null) { allCommits.Add(commit); commit = commit.Parents.FirstOrDefault(); } allCommits.Reverse(); progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_RebuildingCount(allCommits.Count), PercentComplete = 30 }); // Rebuild the commit chain with fixed authorship Commit? newParent = null; var newCommits = new List(); var modifiedCount = 0; for (int i = 0; i < allCommits.Count; i++) { ct.ThrowIfCancellationRequested(); var originalCommit = allCommits[i]; var needsFix = hashesToFix.Contains(originalCommit.Sha); progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, allCommits.Count), PercentComplete = 30 + (i * 60 / allCommits.Count) }); var parents = newParent != null ? new[] { newParent } : Array.Empty(); // Use fixed signature or keep original var author = needsFix ? new Signature(userName, userEmail, originalCommit.Author.When) : originalCommit.Author; var committer = needsFix ? new Signature(userName, userEmail, originalCommit.Committer.When) : originalCommit.Committer; var newCommit = repository.ObjectDatabase.CreateCommit( author, committer, originalCommit.Message, originalCommit.Tree, parents, prettifyMessage: false); newCommits.Add(newCommit); newParent = newCommit; if (needsFix) modifiedCount++; } // Update the branch to point to the new tip if (newParent != null) { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_UpdatingBranch, PercentComplete = 95 }); var branchName = currentBranch.FriendlyName; var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; if (!isDetached && !string.IsNullOrEmpty(branchName)) { var branchRef = repository.Refs[$"refs/heads/{branchName}"]; if (branchRef != null) { repository.Refs.UpdateTarget(branchRef, newParent.Id, $"cleanup: fixed authorship on {modifiedCount} commits"); Commands.Checkout(repository, repository.Branches[branchName], new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } } else { repository.Reset(ResetMode.Hard, newParent); } } return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.FixAuthorship, Success = true, CommitsModified = modifiedCount, NewCommitHashes = newCommits.Select(c => c.Sha).ToList() }); } catch (Exception ex) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.FixAuthorship, Success = false, ErrorMessage = Str.Cleanup_FixAuthorFailed(ex.Message) }); } } private Task ExecuteConsolidateMergesAsync( ManagedRepo repo, CleanupOperation operation, IProgress? progress, CancellationToken ct) { // Consolidate merge fix commits by dropping them from history // The AffectedCommits list contains the fix commits to be removed if (operation.AffectedCommits.Count == 0) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ConsolidateMerges, Success = true, CommitsRemoved = 0, ErrorMessage = Str.Cleanup_NoFixCommits }); } try { // All commits in AffectedCommits are fix commits to be dropped var hashesToDrop = operation.AffectedCommits.ToHashSet(StringComparer.OrdinalIgnoreCase); if (hashesToDrop.Count == 0) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ConsolidateMerges, Success = true, CommitsRemoved = 0 }); } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ConsolidatingFixes, PercentComplete = 10 }); using var repository = new Repository(repo.Path); var currentBranch = repository.Head; if (currentBranch.Tip == null) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ConsolidateMerges, Success = false, ErrorMessage = Str.Cleanup_NoCommitsOnBranch }); } // Collect all commits from HEAD to root (oldest first) var allCommits = new List(); var commit = currentBranch.Tip; while (commit != null) { allCommits.Add(commit); commit = commit.Parents.FirstOrDefault(); } allCommits.Reverse(); // Filter out the fix commits we want to drop var commitsToKeep = allCommits .Where(c => !hashesToDrop.Contains(c.Sha)) .ToList(); if (commitsToKeep.Count == allCommits.Count) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ConsolidateMerges, Success = true, CommitsRemoved = 0, ErrorMessage = Str.Cleanup_NoMatchingFixes }); } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_RebuildingCount(commitsToKeep.Count), PercentComplete = 30 }); // Rebuild the commit chain without the fix commits Commit? newParent = null; var newCommits = new List(); for (int i = 0; i < commitsToKeep.Count; i++) { ct.ThrowIfCancellationRequested(); var originalCommit = commitsToKeep[i]; progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, commitsToKeep.Count), PercentComplete = 30 + (i * 60 / commitsToKeep.Count) }); var parents = newParent != null ? new[] { newParent } : Array.Empty(); var newCommit = repository.ObjectDatabase.CreateCommit( originalCommit.Author, originalCommit.Committer, originalCommit.Message, originalCommit.Tree, parents, prettifyMessage: false); newCommits.Add(newCommit); newParent = newCommit; } // Update the branch to point to the new tip if (newParent != null) { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_UpdatingBranch, PercentComplete = 95 }); var branchName = currentBranch.FriendlyName; var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; if (!isDetached && !string.IsNullOrEmpty(branchName)) { var branchRef = repository.Refs[$"refs/heads/{branchName}"]; if (branchRef != null) { repository.Refs.UpdateTarget(branchRef, newParent.Id, $"cleanup: consolidated {hashesToDrop.Count} merge fix commits"); Commands.Checkout(repository, repository.Branches[branchName], new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } } else { repository.Reset(ResetMode.Hard, newParent); } } return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ConsolidateMerges, Success = true, CommitsRemoved = hashesToDrop.Count, CommitsModified = commitsToKeep.Count, NewCommitHashes = newCommits.Select(c => c.Sha).ToList() }); } catch (Exception ex) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ConsolidateMerges, Success = false, ErrorMessage = Str.Cleanup_ConsolidateFailed(ex.Message) }); } } private Task ExecuteArchiveBranchesAsync( ManagedRepo repo, CleanupOperation operation, IProgress? progress, CancellationToken ct) { // Archive stale branches by deleting them (only if merged) or creating archive tags try { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ArchivingBranches, PercentComplete = 10 }); using var repository = new Repository(repo.Path); var currentBranch = repository.Head.FriendlyName; var deletedCount = 0; var archivedBranches = new List(); // Get all local branches that are not the current branch var staleBranches = repository.Branches .Where(b => !b.IsRemote && b.FriendlyName != currentBranch) .ToList(); for (int i = 0; i < staleBranches.Count; i++) { ct.ThrowIfCancellationRequested(); var branch = staleBranches[i]; progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ProcessingBranch(branch.FriendlyName), PercentComplete = 10 + (i * 80 / Math.Max(1, staleBranches.Count)) }); // Check if branch is merged into current branch var isMerged = repository.Commits .QueryBy(new CommitFilter { IncludeReachableFrom = repository.Head.Tip }) .Any(c => c.Sha == branch.Tip?.Sha); if (isMerged) { // Safe to delete - it's merged repository.Branches.Remove(branch); deletedCount++; archivedBranches.Add(branch.FriendlyName); } else { // Create an archive tag before deleting var tagName = $"archive/{branch.FriendlyName}"; if (branch.Tip != null && repository.Tags[tagName] == null) { repository.ApplyTag(tagName, branch.Tip.Sha, new Signature("GitCleaner", "gitcleaner@local", DateTimeOffset.Now), $"Archived branch: {branch.FriendlyName}"); } repository.Branches.Remove(branch); deletedCount++; archivedBranches.Add($"{branch.FriendlyName} (tagged as {tagName})"); } } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_ArchiveComplete, PercentComplete = 100 }); return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ArchiveBranches, Success = true, CommitsModified = 0, CommitsRemoved = deletedCount, NewCommitHashes = archivedBranches }); } catch (Exception ex) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.ArchiveBranches, Success = false, ErrorMessage = Str.Cleanup_ArchiveFailed(ex.Message) }); } } private Task ExecuteRebaseLinearizeAsync( ManagedRepo repo, CleanupOperation operation, IProgress? progress, CancellationToken ct) { // Linearize history by removing merge commits and creating a single-parent chain // Strategy: Keep all non-merge commits, sorted by author date, rebuilt with linear parents try { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_AnalyzingStructure, PercentComplete = 5 }); using var repository = new Repository(repo.Path); var currentBranch = repository.Head; if (currentBranch.Tip == null) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.RebaseLinearize, Success = false, ErrorMessage = Str.Cleanup_NoCommitsOnBranch }); } // Collect ALL reachable commits (including from merges) var allCommits = new HashSet(); var queue = new Queue(); queue.Enqueue(currentBranch.Tip); while (queue.Count > 0) { var commit = queue.Dequeue(); if (allCommits.Add(commit)) { foreach (var parent in commit.Parents) { queue.Enqueue(parent); } } } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_FoundCommits(allCommits.Count), PercentComplete = 15 }); // Filter out merge commits and sort by author date (oldest first) var nonMergeCommits = allCommits .Where(c => c.Parents.Count() <= 1) // Keep only non-merge commits .OrderBy(c => c.Author.When) .ToList(); var mergeCount = allCommits.Count - nonMergeCommits.Count; if (mergeCount == 0) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.RebaseLinearize, Success = true, CommitsModified = 0, ErrorMessage = Str.Cleanup_AlreadyLinear }); } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_Linearizing(nonMergeCommits.Count, mergeCount), PercentComplete = 20 }); // Rebuild commit chain in linear order Commit? newParent = null; var newCommits = new List(); Tree? lastTree = null; for (int i = 0; i < nonMergeCommits.Count; i++) { ct.ThrowIfCancellationRequested(); var originalCommit = nonMergeCommits[i]; progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_RebuildingCommit(i + 1, nonMergeCommits.Count), PercentComplete = 20 + (i * 70 / nonMergeCommits.Count) }); // Check for potential conflicts by comparing trees // If this commit's tree has the same blob for a path that lastTree modified, // we might have a conflict. For safety, we use the commit's original tree. var parents = newParent != null ? new[] { newParent } : Array.Empty(); // Create the commit with its original tree (preserves the file state at that point) var newCommit = repository.ObjectDatabase.CreateCommit( originalCommit.Author, originalCommit.Committer, originalCommit.Message, originalCommit.Tree, parents, prettifyMessage: false); newCommits.Add(newCommit); newParent = newCommit; lastTree = originalCommit.Tree; } // The final tree should match HEAD's tree for consistency // If it doesn't, create one final commit that brings us to HEAD's state if (newParent != null && currentBranch.Tip.Tree.Id != newParent.Tree.Id) { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_Reconciling, PercentComplete = 92 }); var finalCommit = repository.ObjectDatabase.CreateCommit( currentBranch.Tip.Author, currentBranch.Tip.Committer, Str.Cleanup_ReconcileMerge, currentBranch.Tip.Tree, new[] { newParent }, prettifyMessage: false); newCommits.Add(finalCommit); newParent = finalCommit; } // Update the branch to point to the new tip if (newParent != null) { progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_UpdatingBranch, PercentComplete = 95 }); var branchName = currentBranch.FriendlyName; var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; if (!isDetached && !string.IsNullOrEmpty(branchName)) { var branchRef = repository.Refs[$"refs/heads/{branchName}"]; if (branchRef != null) { repository.Refs.UpdateTarget(branchRef, newParent.Id, $"cleanup: linearized history, removed {mergeCount} merge commits"); Commands.Checkout(repository, repository.Branches[branchName], new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } } else { repository.Reset(ResetMode.Hard, newParent); } } progress?.Report(new CleanupProgress { CurrentOperation = Str.Cleanup_LinearizeComplete, PercentComplete = 100 }); return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.RebaseLinearize, Success = true, CommitsRemoved = mergeCount, CommitsModified = nonMergeCommits.Count, NewCommitHashes = newCommits.Select(c => c.Sha).ToList() }); } catch (Exception ex) { return Task.FromResult(new CleanupExecutionResult { OperationId = operation.Id, Type = CleanupType.RebaseLinearize, Success = false, ErrorMessage = Str.Cleanup_LinearizeFailed(ex.Message) }); } } private static string CleanupMessage(string message) { var lines = message.Split('\n'); if (lines.Length == 0) return message; var subject = lines[0].Trim(); // Capitalize first letter if (subject.Length > 0 && char.IsLower(subject[0])) { subject = char.ToUpper(subject[0]) + subject[1..]; } // Remove trailing period if (subject.EndsWith('.')) { subject = subject[..^1]; } // Ensure reasonable length if (subject.Length > 72) { subject = subject[..69] + "..."; } if (lines.Length > 1) { return subject + "\n" + string.Join("\n", lines.Skip(1)); } return subject; } private static string GeneratePreviewSummary(CleanupOperation operation) { return operation.Type switch { CleanupType.RewordMessages => Str.Cleanup_DescReword(operation.AffectedCommits.Count), CleanupType.SquashDuplicates => Str.Cleanup_DescSquash(operation.AffectedCommits.Count), CleanupType.SquashMerges => Str.Cleanup_DescSquashMerges(operation.AffectedCommits.Count), CleanupType.FixAuthorship => Str.Cleanup_DescFixAuthor(operation.AffectedCommits.Count), CleanupType.ConsolidateMerges => Str.Cleanup_DescConsolidate(operation.AffectedCommits.Count), CleanupType.ArchiveBranches => Str.Cleanup_DescArchive, CleanupType.RebaseLinearize => Str.Cleanup_DescLinearize, _ => Str.Cleanup_DescDefault(operation.AffectedCommits.Count) }; } }