1320 lines
47 KiB
C#
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)
|
|
};
|
|
}
|
|
}
|