Files
2025-12-28 05:38:14 -05:00

1320 lines
47 KiB
C#

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;
/// <summary>
/// Executes cleanup operations on git repositories.
/// </summary>
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<CleanupExecutionResult> ExecuteAsync(
ManagedRepo repo,
CleanupOperation operation,
CleanupExecutionOptions? options = null,
IProgress<CleanupProgress>? 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<BatchCleanupResult> ExecuteBatchAsync(
ManagedRepo repo,
IEnumerable<CleanupOperation> operations,
CleanupExecutionOptions? options = null,
IProgress<CleanupProgress>? progress = null,
CancellationToken ct = default)
{
options ??= new CleanupExecutionOptions();
var opList = operations.ToList();
var results = new List<CleanupExecutionResult>();
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<CleanupPreview> 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<string> 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<CleanupExecutionResult> ExecuteRewordAsync(
ManagedRepo repo,
CleanupOperation operation,
CleanupExecutionOptions options,
IProgress<CleanupProgress>? progress,
CancellationToken ct)
{
var modifiedHashes = new List<string>();
var newHashes = new List<string>();
foreach (var commitHash in operation.AffectedCommits)
{
ct.ThrowIfCancellationRequested();
try
{
// Get the commit
using var repository = new Repository(repo.Path);
var commit = repository.Lookup<Commit>(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<CleanupExecutionResult> ExecuteSquashDuplicatesAsync(
ManagedRepo repo,
CleanupOperation operation,
IProgress<CleanupProgress>? 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<Commit>();
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<Commit>();
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<Commit>();
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<CleanupExecutionResult> ExecuteSquashMergesAsync(
ManagedRepo repo,
CleanupOperation operation,
IProgress<CleanupProgress>? 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<Commit>();
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<Commit>();
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<Commit>();
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<CleanupExecutionResult> ExecuteFixAuthorshipAsync(
ManagedRepo repo,
CleanupOperation operation,
IProgress<CleanupProgress>? 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<string>("user.name")?.Value ?? "Unknown";
var userEmail = config.Get<string>("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<Commit>();
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<Commit>();
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<Commit>();
// 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<CleanupExecutionResult> ExecuteConsolidateMergesAsync(
ManagedRepo repo,
CleanupOperation operation,
IProgress<CleanupProgress>? 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<Commit>();
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<Commit>();
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<Commit>();
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<CleanupExecutionResult> ExecuteArchiveBranchesAsync(
ManagedRepo repo,
CleanupOperation operation,
IProgress<CleanupProgress>? 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<string>();
// 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<CleanupExecutionResult> ExecuteRebaseLinearizeAsync(
ManagedRepo repo,
CleanupOperation operation,
IProgress<CleanupProgress>? 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<Commit>();
var queue = new Queue<Commit>();
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<Commit>();
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<Commit>();
// 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)
};
}
}