1001 lines
34 KiB
C#
Executable File
1001 lines
34 KiB
C#
Executable File
using System.Collections.Concurrent;
|
|
using MarketAlly.LibGit2Sharp;
|
|
using MarketAlly.GitCommitEditor.Models;
|
|
using MarketAlly.GitCommitEditor.Resources;
|
|
|
|
namespace MarketAlly.GitCommitEditor.Services;
|
|
|
|
public sealed class GitOperationsService : IGitOperationsService
|
|
{
|
|
private readonly ConcurrentDictionary<string, CachedRepository> _repoCache = new();
|
|
private readonly Timer _cleanupTimer;
|
|
private readonly int _maxCacheSize;
|
|
private readonly TimeSpan _cacheTtl;
|
|
private bool _disposed;
|
|
|
|
private sealed class CachedRepository(Repository repository)
|
|
{
|
|
public Repository Repository { get; } = repository;
|
|
public DateTime LastAccessed { get; set; } = DateTime.UtcNow;
|
|
}
|
|
|
|
public GitOperationsService(int maxCacheSize = 20, TimeSpan? cacheTtl = null)
|
|
{
|
|
_maxCacheSize = maxCacheSize;
|
|
_cacheTtl = cacheTtl ?? TimeSpan.FromMinutes(30);
|
|
_cleanupTimer = new Timer(CleanupExpiredRepositories, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
|
|
}
|
|
|
|
private void CleanupExpiredRepositories(object? state)
|
|
{
|
|
var expiredKeys = _repoCache
|
|
.Where(kvp => DateTime.UtcNow - kvp.Value.LastAccessed > _cacheTtl)
|
|
.Select(kvp => kvp.Key)
|
|
.ToList();
|
|
|
|
foreach (var key in expiredKeys)
|
|
{
|
|
if (_repoCache.TryRemove(key, out var cached))
|
|
{
|
|
cached.Repository.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes a path for consistent cache key comparison.
|
|
/// On Windows, paths are case-insensitive, so we normalize to lowercase.
|
|
/// </summary>
|
|
private static string NormalizePath(string path)
|
|
{
|
|
// Get full path to resolve any relative paths or ../ segments
|
|
var fullPath = Path.GetFullPath(path);
|
|
|
|
// On Windows, normalize to lowercase for case-insensitive comparison
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
return fullPath.ToLowerInvariant();
|
|
}
|
|
|
|
return fullPath;
|
|
}
|
|
|
|
private Repository GetRepository(string path)
|
|
{
|
|
var normalizedPath = NormalizePath(path);
|
|
|
|
// Evict oldest if at capacity
|
|
while (_repoCache.Count >= _maxCacheSize)
|
|
{
|
|
var oldest = _repoCache
|
|
.OrderBy(kvp => kvp.Value.LastAccessed)
|
|
.FirstOrDefault();
|
|
|
|
if (oldest.Key != null && _repoCache.TryRemove(oldest.Key, out var removed))
|
|
{
|
|
removed.Repository.Dispose();
|
|
}
|
|
}
|
|
|
|
// Use normalized path for cache key, but original path for Repository constructor
|
|
var cached = _repoCache.GetOrAdd(normalizedPath, _ => new CachedRepository(new Repository(path)));
|
|
cached.LastAccessed = DateTime.UtcNow;
|
|
return cached.Repository;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invalidates the cached Repository for the given path, forcing a fresh instance on next access.
|
|
/// Call this after operations that modify git history (rewrite, rebase, etc.)
|
|
/// </summary>
|
|
public void InvalidateCache(string path)
|
|
{
|
|
var normalizedPath = NormalizePath(path);
|
|
if (_repoCache.TryRemove(normalizedPath, out var cached))
|
|
{
|
|
cached.Repository.Dispose();
|
|
}
|
|
}
|
|
|
|
public IEnumerable<string> DiscoverRepositories(string rootPath, int maxDepth = 3)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rootPath);
|
|
|
|
if (!Directory.Exists(rootPath))
|
|
yield break;
|
|
|
|
var queue = new Queue<(string Path, int Depth)>();
|
|
queue.Enqueue((rootPath, 0));
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var (currentPath, depth) = queue.Dequeue();
|
|
|
|
var gitDir = Path.Combine(currentPath, ".git");
|
|
if (Directory.Exists(gitDir) || File.Exists(gitDir))
|
|
{
|
|
yield return currentPath;
|
|
continue;
|
|
}
|
|
|
|
if (depth >= maxDepth)
|
|
continue;
|
|
|
|
try
|
|
{
|
|
foreach (var subDir in Directory.GetDirectories(currentPath))
|
|
{
|
|
var name = Path.GetFileName(subDir);
|
|
if (name.StartsWith('.') ||
|
|
name is "node_modules" or "bin" or "obj" or "packages" or ".git")
|
|
continue;
|
|
|
|
queue.Enqueue((subDir, depth + 1));
|
|
}
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
// Skip directories we can't access
|
|
}
|
|
catch (DirectoryNotFoundException)
|
|
{
|
|
// Skip directories that were deleted during enumeration
|
|
}
|
|
}
|
|
}
|
|
|
|
public ManagedRepo CreateManagedRepo(string repoPath)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
|
|
using var repo = new Repository(repoPath);
|
|
|
|
var remoteUrl = repo.Network.Remotes.FirstOrDefault()?.Url;
|
|
var currentBranch = repo.Head.FriendlyName;
|
|
var name = Path.GetFileName(repoPath);
|
|
|
|
return new ManagedRepo
|
|
{
|
|
Name = name,
|
|
Path = repoPath,
|
|
RemoteUrl = remoteUrl,
|
|
CurrentBranch = currentBranch
|
|
};
|
|
}
|
|
|
|
public IEnumerable<BranchInfo> GetBranches(string repoPath)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
|
|
var repo = GetRepository(repoPath);
|
|
|
|
foreach (var branch in repo.Branches)
|
|
{
|
|
yield return new BranchInfo
|
|
{
|
|
Name = branch.FriendlyName,
|
|
FullName = branch.CanonicalName,
|
|
IsRemote = branch.IsRemote,
|
|
IsCurrentHead = branch.IsCurrentRepositoryHead,
|
|
LastCommitSha = branch.Tip?.Sha,
|
|
LastCommitDate = branch.Tip?.Author.When,
|
|
RemoteName = branch.IsRemote ? branch.RemoteName : null
|
|
};
|
|
}
|
|
}
|
|
|
|
public IEnumerable<CommitAnalysis> AnalyzeCommits(
|
|
ManagedRepo managedRepo,
|
|
ICommitMessageAnalyzer analyzer,
|
|
int maxCommits = 100,
|
|
DateTimeOffset? since = null,
|
|
string[]? excludeAuthors = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(managedRepo);
|
|
ArgumentNullException.ThrowIfNull(analyzer);
|
|
|
|
// Always invalidate cache before analyzing to ensure we see the latest commits
|
|
// This is critical after rewrite operations that change commit history
|
|
InvalidateCache(managedRepo.Path);
|
|
|
|
var repo = GetRepository(managedRepo.Path);
|
|
var headTip = repo.Head.Tip;
|
|
var headSha = headTip?.Sha;
|
|
|
|
if (headTip == null)
|
|
yield break;
|
|
|
|
// Explicitly walk from HEAD to avoid picking up unreachable commits
|
|
// After a rewrite, old commits still exist in the object database but are unreachable from HEAD
|
|
var commitFilter = new CommitFilter
|
|
{
|
|
IncludeReachableFrom = headTip,
|
|
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time
|
|
};
|
|
|
|
var commits = since.HasValue
|
|
? repo.Commits.QueryBy(commitFilter).Where(c => c.Author.When >= since.Value).Take(maxCommits)
|
|
: repo.Commits.QueryBy(commitFilter).Take(maxCommits);
|
|
|
|
foreach (var commit in commits)
|
|
{
|
|
if (excludeAuthors?.Contains(commit.Author.Email, StringComparer.OrdinalIgnoreCase) == true)
|
|
continue;
|
|
|
|
var parent = commit.Parents.FirstOrDefault();
|
|
var changes = repo.Diff.Compare<TreeChanges>(parent?.Tree, commit.Tree);
|
|
var patch = repo.Diff.Compare<Patch>(parent?.Tree, commit.Tree);
|
|
|
|
var context = new CommitContext
|
|
{
|
|
FilesChanged = changes.Count(),
|
|
LinesAdded = patch.LinesAdded,
|
|
LinesDeleted = patch.LinesDeleted,
|
|
FileNames = changes.Select(c => c.Path).ToList()
|
|
};
|
|
|
|
var quality = analyzer.Analyze(commit.Message, context);
|
|
|
|
// Build per-file diffs dictionary
|
|
var fileDiffs = new Dictionary<string, string>();
|
|
foreach (var patchEntry in patch)
|
|
{
|
|
fileDiffs[patchEntry.Path] = TruncateDiff(patchEntry.Patch, 100);
|
|
}
|
|
|
|
yield return new CommitAnalysis
|
|
{
|
|
RepoId = managedRepo.Id,
|
|
RepoName = managedRepo.Name,
|
|
RepoPath = managedRepo.Path,
|
|
CommitHash = commit.Sha,
|
|
OriginalMessage = commit.Message.Trim(),
|
|
CommitDate = commit.Author.When,
|
|
Author = commit.Author.Name,
|
|
AuthorEmail = commit.Author.Email,
|
|
Quality = quality,
|
|
FilesChanged = changes.Select(c => c.Path).ToList(),
|
|
DiffSummary = TruncateDiff(patch.Content, 200),
|
|
FileDiffs = fileDiffs,
|
|
LinesAdded = patch.LinesAdded,
|
|
LinesDeleted = patch.LinesDeleted,
|
|
IsLatestCommit = commit.Sha == headSha
|
|
};
|
|
}
|
|
}
|
|
|
|
public RewriteOperation AmendLatestCommit(ManagedRepo managedRepo, string newMessage)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(managedRepo);
|
|
ArgumentNullException.ThrowIfNull(newMessage);
|
|
|
|
var repo = GetRepository(managedRepo.Path);
|
|
var head = repo.Head.Tip ?? throw new InvalidOperationException(Str.Git_NoCommits);
|
|
|
|
var operation = new RewriteOperation
|
|
{
|
|
RepoId = managedRepo.Id,
|
|
RepoPath = managedRepo.Path,
|
|
CommitHash = head.Sha,
|
|
OriginalMessage = head.Message,
|
|
NewMessage = newMessage,
|
|
IsLatestCommit = true
|
|
};
|
|
|
|
try
|
|
{
|
|
var amended = repo.Commit(
|
|
newMessage,
|
|
head.Author,
|
|
head.Committer,
|
|
new CommitOptions { AmendPreviousCommit = true });
|
|
|
|
operation.NewCommitHash = amended.Sha;
|
|
operation.Status = OperationStatus.Applied;
|
|
operation.AppliedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
catch (LibGit2SharpException ex)
|
|
{
|
|
operation.Status = OperationStatus.Failed;
|
|
operation.ErrorMessage = Str.Git_Error(ex.Message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
operation.Status = OperationStatus.Failed;
|
|
operation.ErrorMessage = ex.Message;
|
|
}
|
|
|
|
return operation;
|
|
}
|
|
|
|
public RewriteOperation RewordOlderCommit(ManagedRepo managedRepo, string commitHash, string newMessage)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(managedRepo);
|
|
ArgumentNullException.ThrowIfNull(commitHash);
|
|
ArgumentNullException.ThrowIfNull(newMessage);
|
|
|
|
var repo = GetRepository(managedRepo.Path);
|
|
|
|
var targetCommit = repo.Lookup<Commit>(commitHash)
|
|
?? throw new ArgumentException(Str.Git_CommitNotFound(commitHash), nameof(commitHash));
|
|
|
|
var operation = new RewriteOperation
|
|
{
|
|
RepoId = managedRepo.Id,
|
|
RepoPath = managedRepo.Path,
|
|
CommitHash = commitHash,
|
|
OriginalMessage = targetCommit.Message,
|
|
NewMessage = newMessage,
|
|
IsLatestCommit = repo.Head.Tip?.Sha == commitHash
|
|
};
|
|
|
|
if (operation.IsLatestCommit)
|
|
{
|
|
return AmendLatestCommit(managedRepo, newMessage);
|
|
}
|
|
|
|
try
|
|
{
|
|
var commitsToRewrite = repo.Commits
|
|
.QueryBy(new CommitFilter
|
|
{
|
|
IncludeReachableFrom = repo.Head.Tip,
|
|
ExcludeReachableFrom = targetCommit.Parents
|
|
})
|
|
.Reverse()
|
|
.ToList();
|
|
|
|
if (!commitsToRewrite.Any(c => c.Sha == targetCommit.Sha))
|
|
throw new InvalidOperationException(Str.Git_NotAncestor);
|
|
|
|
var rewriteMap = new Dictionary<string, Commit>();
|
|
Commit? newHead = null;
|
|
|
|
foreach (var oldCommit in commitsToRewrite)
|
|
{
|
|
var message = oldCommit.Sha == targetCommit.Sha
|
|
? newMessage
|
|
: oldCommit.Message;
|
|
|
|
var newParents = oldCommit.Parents
|
|
.Select(p => rewriteMap.TryGetValue(p.Sha, out var mapped) ? mapped : p)
|
|
.ToList();
|
|
|
|
var newCommit = repo.ObjectDatabase.CreateCommit(
|
|
oldCommit.Author,
|
|
oldCommit.Committer,
|
|
message,
|
|
oldCommit.Tree,
|
|
newParents,
|
|
prettifyMessage: false);
|
|
|
|
rewriteMap[oldCommit.Sha] = newCommit;
|
|
newHead = newCommit;
|
|
}
|
|
|
|
if (newHead != null)
|
|
{
|
|
repo.Refs.UpdateTarget(repo.Head.CanonicalName, newHead.Id.Sha);
|
|
}
|
|
|
|
operation.NewCommitHash = rewriteMap.TryGetValue(commitHash, out var rewritten)
|
|
? rewritten.Sha
|
|
: null;
|
|
operation.Status = OperationStatus.Applied;
|
|
operation.AppliedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
catch (LibGit2SharpException ex)
|
|
{
|
|
operation.Status = OperationStatus.Failed;
|
|
operation.ErrorMessage = Str.Git_Error(ex.Message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
operation.Status = OperationStatus.Failed;
|
|
operation.ErrorMessage = ex.Message;
|
|
}
|
|
|
|
return operation;
|
|
}
|
|
|
|
public List<RewriteOperation> RewordMultipleCommits(ManagedRepo managedRepo, Dictionary<string, string> rewrites)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(managedRepo);
|
|
ArgumentNullException.ThrowIfNull(rewrites);
|
|
|
|
var operations = new List<RewriteOperation>();
|
|
|
|
if (rewrites.Count == 0)
|
|
return operations;
|
|
|
|
// Disable libgit2's internal object cache to prevent stale objects during rewrite
|
|
GlobalSettings.SetEnableCaching(false);
|
|
|
|
// Use a fresh Repository (not cached) to ensure changes are flushed to disk
|
|
// The cached repo might have stale state or not flush properly
|
|
InvalidateCache(managedRepo.Path);
|
|
var repo = new Repository(managedRepo.Path);
|
|
|
|
// Validate all commits exist and build operation list
|
|
// Cache OriginalMessage now because commit objects become stale after cache invalidation
|
|
var commitInfos = new List<(string OriginalHash, string OriginalMessage, string NewMessage)>();
|
|
foreach (var (hash, newMessage) in rewrites)
|
|
{
|
|
var commit = repo.Lookup<Commit>(hash);
|
|
if (commit == null)
|
|
{
|
|
operations.Add(new RewriteOperation
|
|
{
|
|
RepoId = managedRepo.Id,
|
|
RepoPath = managedRepo.Path,
|
|
CommitHash = hash,
|
|
OriginalMessage = "",
|
|
NewMessage = newMessage,
|
|
Status = OperationStatus.Failed,
|
|
ErrorMessage = Str.Git_CommitNotFound(hash)
|
|
});
|
|
continue;
|
|
}
|
|
commitInfos.Add((hash, commit.Message, newMessage));
|
|
}
|
|
|
|
if (commitInfos.Count == 0)
|
|
return operations;
|
|
|
|
try
|
|
{
|
|
// Find the oldest commit in git ancestry by walking from HEAD
|
|
// The commit that appears LAST when walking from HEAD is the oldest ancestor
|
|
var commitHashes = new HashSet<string>(commitInfos.Select(c => c.OriginalHash));
|
|
|
|
Commit? oldestCommit = null;
|
|
foreach (var commit in repo.Commits.QueryBy(new CommitFilter { IncludeReachableFrom = repo.Head.Tip }))
|
|
{
|
|
if (commitHashes.Contains(commit.Sha))
|
|
{
|
|
oldestCommit = commit; // Keep updating - last one found is oldest
|
|
}
|
|
}
|
|
|
|
// Fallback: if none found in walk (shouldn't happen), look up first commit
|
|
if (oldestCommit == null && commitInfos.Count > 0)
|
|
{
|
|
oldestCommit = repo.Lookup<Commit>(commitInfos[0].OriginalHash);
|
|
}
|
|
|
|
if (oldestCommit == null)
|
|
{
|
|
throw new InvalidOperationException(Str.Git_NoTargetCommits);
|
|
}
|
|
|
|
// Get all commits from oldest's parents to HEAD
|
|
// CRITICAL: Use Topological+Reverse to ensure parents come before children
|
|
// This is essential for rewriting - we must process parents first so their
|
|
// new hashes are in rewriteMap when we process their children
|
|
// Topological alone = children before parents (git default for log)
|
|
// Topological | Reverse = parents before children (what we need for rewriting)
|
|
var commitsToRewrite = repo.Commits
|
|
.QueryBy(new CommitFilter
|
|
{
|
|
IncludeReachableFrom = repo.Head.Tip,
|
|
ExcludeReachableFrom = oldestCommit.Parents,
|
|
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Reverse
|
|
})
|
|
.ToList();
|
|
|
|
// Build a lookup of commits to rewrite
|
|
var rewriteLookup = commitInfos.ToDictionary(c => c.OriginalHash, c => c);
|
|
|
|
// Rewrite all commits in a single pass
|
|
var rewriteMap = new Dictionary<string, Commit>();
|
|
Commit? newHead = null;
|
|
|
|
foreach (var oldCommit in commitsToRewrite)
|
|
{
|
|
// Use new message if this commit is being rewritten, otherwise keep original
|
|
var message = rewriteLookup.TryGetValue(oldCommit.Sha, out var info)
|
|
? info.NewMessage
|
|
: oldCommit.Message;
|
|
|
|
// Map parents to their rewritten versions
|
|
var newParents = oldCommit.Parents
|
|
.Select(p => rewriteMap.TryGetValue(p.Sha, out var mapped) ? mapped : p)
|
|
.ToList();
|
|
|
|
var newCommit = repo.ObjectDatabase.CreateCommit(
|
|
oldCommit.Author,
|
|
oldCommit.Committer,
|
|
message,
|
|
oldCommit.Tree,
|
|
newParents,
|
|
prettifyMessage: false);
|
|
|
|
// Verify the created commit has the correct parents
|
|
var createdParents = newCommit.Parents.Select(p => p.Sha).ToList();
|
|
var expectedParents = newParents.Select(p => p.Sha).ToList();
|
|
if (!createdParents.SequenceEqual(expectedParents))
|
|
{
|
|
throw new InvalidOperationException(Str.Git_ParentMismatch(oldCommit.Sha[..7]));
|
|
}
|
|
|
|
rewriteMap[oldCommit.Sha] = newCommit;
|
|
newHead = newCommit;
|
|
}
|
|
|
|
// Update HEAD to new commit chain using hard reset
|
|
// This is more robust than just updating refs because it also syncs index and working tree
|
|
if (newHead != null)
|
|
{
|
|
// Hard reset updates HEAD, index, AND working tree atomically
|
|
repo.Reset(ResetMode.Hard, newHead);
|
|
|
|
// Verify the HEAD was actually updated
|
|
var verifyHead = repo.Head.Tip;
|
|
if (verifyHead?.Sha != newHead.Sha)
|
|
{
|
|
throw new InvalidOperationException(Str.Git_HeadUpdateFailed(newHead.Sha[..7]));
|
|
}
|
|
}
|
|
|
|
// Store the expected new HEAD SHA before disposing (null if no commits rewritten)
|
|
var expectedHeadSha = newHead?.Sha;
|
|
|
|
// Explicitly dispose repository to flush changes to disk
|
|
repo.Dispose();
|
|
repo = null!;
|
|
|
|
// Invalidate cache to ensure fresh state
|
|
InvalidateCache(managedRepo.Path);
|
|
|
|
// Verify the changes persisted to disk and no old commits are reachable
|
|
if (expectedHeadSha != null)
|
|
{
|
|
using (var verifyRepo = new Repository(managedRepo.Path))
|
|
{
|
|
var diskHead = verifyRepo.Head.Tip;
|
|
if (diskHead?.Sha != expectedHeadSha)
|
|
{
|
|
throw new InvalidOperationException(Str.Git_VerificationFailed(expectedHeadSha[..7], diskHead?.Sha?[..7] ?? "null"));
|
|
}
|
|
|
|
// Walk the new chain and verify no original hashes appear
|
|
var originalHashes = new HashSet<string>(commitsToRewrite.Select(c => c.Sha));
|
|
var walkCount = 0;
|
|
var maxWalk = commitsToRewrite.Count + 10;
|
|
foreach (var c in verifyRepo.Commits.QueryBy(new CommitFilter { IncludeReachableFrom = diskHead }))
|
|
{
|
|
if (walkCount++ > maxWalk) break;
|
|
|
|
if (originalHashes.Contains(c.Sha))
|
|
{
|
|
throw new InvalidOperationException(Str.Git_OldCommitReachable(c.Sha[..7]));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build successful operation results
|
|
foreach (var (originalHash, originalMessage, newMessage) in commitInfos)
|
|
{
|
|
var newHash = rewriteMap.TryGetValue(originalHash, out var rewritten)
|
|
? rewritten.Sha
|
|
: null;
|
|
|
|
operations.Add(new RewriteOperation
|
|
{
|
|
RepoId = managedRepo.Id,
|
|
RepoPath = managedRepo.Path,
|
|
CommitHash = originalHash,
|
|
OriginalMessage = originalMessage,
|
|
NewMessage = newMessage,
|
|
NewCommitHash = newHash,
|
|
Status = OperationStatus.Applied,
|
|
AppliedAt = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
|
|
// Re-enable libgit2 caching after successful rewrite
|
|
GlobalSettings.SetEnableCaching(true);
|
|
}
|
|
catch (LibGit2SharpException ex)
|
|
{
|
|
// Ensure repo is disposed even on error
|
|
repo?.Dispose();
|
|
InvalidateCache(managedRepo.Path);
|
|
|
|
// Re-enable libgit2 caching on error
|
|
GlobalSettings.SetEnableCaching(true);
|
|
|
|
// Mark all remaining as failed
|
|
foreach (var (originalHash, originalMessage, newMessage) in commitInfos)
|
|
{
|
|
if (!operations.Any(o => o.CommitHash == originalHash))
|
|
{
|
|
operations.Add(new RewriteOperation
|
|
{
|
|
RepoId = managedRepo.Id,
|
|
RepoPath = managedRepo.Path,
|
|
CommitHash = originalHash,
|
|
OriginalMessage = originalMessage,
|
|
NewMessage = newMessage,
|
|
Status = OperationStatus.Failed,
|
|
ErrorMessage = Str.Git_Error(ex.Message)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Ensure repo is disposed even on error
|
|
repo?.Dispose();
|
|
InvalidateCache(managedRepo.Path);
|
|
|
|
// Re-enable libgit2 caching on error
|
|
GlobalSettings.SetEnableCaching(true);
|
|
|
|
foreach (var (originalHash, originalMessage, newMessage) in commitInfos)
|
|
{
|
|
if (!operations.Any(o => o.CommitHash == originalHash))
|
|
{
|
|
operations.Add(new RewriteOperation
|
|
{
|
|
RepoId = managedRepo.Id,
|
|
RepoPath = managedRepo.Path,
|
|
CommitHash = originalHash,
|
|
OriginalMessage = originalMessage,
|
|
NewMessage = newMessage,
|
|
Status = OperationStatus.Failed,
|
|
ErrorMessage = ex.Message
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return operations;
|
|
}
|
|
|
|
public bool IsCommitPushed(string repoPath, string commitHash)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
ArgumentNullException.ThrowIfNull(commitHash);
|
|
|
|
try
|
|
{
|
|
var repo = GetRepository(repoPath);
|
|
var commit = repo.Lookup<Commit>(commitHash);
|
|
if (commit == null) return false;
|
|
|
|
var head = repo.Head;
|
|
if (!head.IsTracking || head.TrackedBranch?.Tip == null)
|
|
return false;
|
|
|
|
var remoteTip = head.TrackedBranch.Tip;
|
|
var filter = new CommitFilter { IncludeReachableFrom = remoteTip };
|
|
|
|
return repo.Commits.QueryBy(filter).Any(c => c.Sha == commitHash);
|
|
}
|
|
catch (LibGit2SharpException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public TrackingInfo GetTrackingInfo(string repoPath)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
|
|
try
|
|
{
|
|
var repo = GetRepository(repoPath);
|
|
var head = repo.Head;
|
|
|
|
if (!head.IsTracking)
|
|
return TrackingInfo.None;
|
|
|
|
var tracking = head.TrackingDetails;
|
|
return new TrackingInfo(
|
|
head.TrackedBranch?.FriendlyName,
|
|
tracking.AheadBy,
|
|
tracking.BehindBy);
|
|
}
|
|
catch (LibGit2SharpException)
|
|
{
|
|
return TrackingInfo.None;
|
|
}
|
|
}
|
|
|
|
public GitPushResult ForcePush(string repoPath, PushOptions? options = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
|
|
try
|
|
{
|
|
var repo = GetRepository(repoPath);
|
|
var head = repo.Head;
|
|
var branchName = head.FriendlyName;
|
|
|
|
Remote remote;
|
|
string forceRefSpec;
|
|
|
|
if (head.IsTracking)
|
|
{
|
|
// Branch has upstream - force push to tracked remote
|
|
remote = repo.Network.Remotes[head.RemoteName];
|
|
if (remote == null)
|
|
return GitPushResult.Fail(Str.Git_RemoteNotFound(head.RemoteName));
|
|
|
|
forceRefSpec = $"+{head.CanonicalName}:{head.UpstreamBranchCanonicalName}";
|
|
}
|
|
else
|
|
{
|
|
// No upstream - try to push to origin
|
|
remote = repo.Network.Remotes["origin"];
|
|
if (remote == null)
|
|
return GitPushResult.Fail(Str.Git_NoUpstreamNoOrigin(branchName));
|
|
|
|
// Force push to origin with the same branch name
|
|
forceRefSpec = $"+{head.CanonicalName}:refs/heads/{branchName}";
|
|
}
|
|
|
|
// Use provided options or create default with credentials handler
|
|
var pushOptions = options ?? CreateDefaultPushOptions();
|
|
|
|
repo.Network.Push(remote, forceRefSpec, pushOptions);
|
|
|
|
return GitPushResult.Ok(head.IsTracking
|
|
? Str.Git_ForcePushSuccess
|
|
: Str.Git_ForcePushedTo(branchName));
|
|
}
|
|
catch (LibGit2SharpException ex)
|
|
{
|
|
// Provide more helpful error message for auth failures
|
|
if (ex.Message.Contains("authentication") || ex.Message.Contains("credential"))
|
|
{
|
|
// Try using system git as fallback
|
|
return ForcePushViaGitCommand(repoPath);
|
|
}
|
|
return GitPushResult.Fail(Str.Git_Error(ex.Message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return GitPushResult.Fail(ex.Message);
|
|
}
|
|
}
|
|
|
|
private static GitPushResult ForcePushViaGitCommand(string repoPath)
|
|
{
|
|
try
|
|
{
|
|
var startInfo = new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "git",
|
|
Arguments = "push --force-with-lease",
|
|
WorkingDirectory = repoPath,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
};
|
|
|
|
using var process = System.Diagnostics.Process.Start(startInfo);
|
|
if (process == null)
|
|
return GitPushResult.Fail(Str.Git_ProcessFailed);
|
|
|
|
var output = process.StandardOutput.ReadToEnd();
|
|
var error = process.StandardError.ReadToEnd();
|
|
process.WaitForExit(30000); // 30 second timeout
|
|
|
|
if (process.ExitCode == 0)
|
|
{
|
|
return GitPushResult.Ok(Str.Git_ForcePushSuccessCmd);
|
|
}
|
|
else
|
|
{
|
|
// Git often outputs progress to stderr even on success
|
|
var message = !string.IsNullOrEmpty(error) ? error : output;
|
|
return GitPushResult.Fail(Str.Git_PushFailed(message.Trim()));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return GitPushResult.Fail(Str.Git_CommandFailed(ex.Message));
|
|
}
|
|
}
|
|
|
|
private static PushOptions CreateDefaultPushOptions()
|
|
{
|
|
return new PushOptions
|
|
{
|
|
CredentialsProvider = (url, usernameFromUrl, types) =>
|
|
{
|
|
// Try to use default credentials (works with Git Credential Manager on Windows)
|
|
return new DefaultCredentials();
|
|
}
|
|
};
|
|
}
|
|
|
|
public GitPushResult Push(string repoPath, PushOptions? options = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
|
|
try
|
|
{
|
|
var repo = GetRepository(repoPath);
|
|
var head = repo.Head;
|
|
|
|
if (!head.IsTracking)
|
|
return GitPushResult.Fail(Str.Git_NoUpstream);
|
|
|
|
var pushOptions = options ?? CreateDefaultPushOptions();
|
|
repo.Network.Push(head, pushOptions);
|
|
return GitPushResult.Ok();
|
|
}
|
|
catch (NonFastForwardException)
|
|
{
|
|
return GitPushResult.Fail(Str.Git_NonFastForward);
|
|
}
|
|
catch (LibGit2SharpException ex)
|
|
{
|
|
if (ex.Message.Contains("authentication") || ex.Message.Contains("credential"))
|
|
{
|
|
// Try using system git as fallback
|
|
return PushViaGitCommand(repoPath);
|
|
}
|
|
return GitPushResult.Fail(Str.Git_Error(ex.Message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return GitPushResult.Fail(ex.Message);
|
|
}
|
|
}
|
|
|
|
private static GitPushResult PushViaGitCommand(string repoPath)
|
|
{
|
|
try
|
|
{
|
|
var startInfo = new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "git",
|
|
Arguments = "push",
|
|
WorkingDirectory = repoPath,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
};
|
|
|
|
using var process = System.Diagnostics.Process.Start(startInfo);
|
|
if (process == null)
|
|
return GitPushResult.Fail(Str.Git_ProcessFailed);
|
|
|
|
var output = process.StandardOutput.ReadToEnd();
|
|
var error = process.StandardError.ReadToEnd();
|
|
process.WaitForExit(30000);
|
|
|
|
if (process.ExitCode == 0)
|
|
{
|
|
return GitPushResult.Ok(Str.Git_PushSuccessCmd);
|
|
}
|
|
else
|
|
{
|
|
var message = !string.IsNullOrEmpty(error) ? error : output;
|
|
return GitPushResult.Fail(Str.Git_PushFailed(message.Trim()));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return GitPushResult.Fail(Str.Git_CommandFailed(ex.Message));
|
|
}
|
|
}
|
|
|
|
public bool UndoCommitAmend(string repoPath, string originalCommitHash)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
ArgumentNullException.ThrowIfNull(originalCommitHash);
|
|
|
|
try
|
|
{
|
|
var repo = GetRepository(repoPath);
|
|
var originalCommit = repo.Lookup<Commit>(originalCommitHash);
|
|
|
|
if (originalCommit == null)
|
|
{
|
|
var reflog = repo.Refs.Log(repo.Head.CanonicalName);
|
|
var entry = reflog?.FirstOrDefault(e => e.To.Sha == originalCommitHash);
|
|
if (entry != null)
|
|
{
|
|
originalCommit = repo.Lookup<Commit>(entry.To);
|
|
}
|
|
}
|
|
|
|
if (originalCommit == null)
|
|
return false;
|
|
|
|
repo.Reset(ResetMode.Soft, originalCommit);
|
|
return true;
|
|
}
|
|
catch (LibGit2SharpException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string TruncateDiff(string diff, int maxLines)
|
|
{
|
|
var lines = diff.Split('\n');
|
|
if (lines.Length <= maxLines)
|
|
return diff;
|
|
|
|
return string.Join('\n', lines.Take(maxLines)) + $"\n... ({lines.Length - maxLines} more lines)";
|
|
}
|
|
|
|
public IEnumerable<BackupBranchInfo> GetBackupBranches(string repoPath)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
|
|
// Use a fresh repository to ensure we get the latest branch state
|
|
// Don't use cache here as branches may have been deleted
|
|
using var repo = new Repository(repoPath);
|
|
var branches = repo.Branches
|
|
.Where(b => !b.IsRemote && b.FriendlyName.StartsWith("backup/"))
|
|
.ToList(); // Materialize to avoid enumeration issues with using
|
|
|
|
foreach (var branch in branches)
|
|
{
|
|
yield return new BackupBranchInfo(
|
|
branch.FriendlyName,
|
|
branch.CanonicalName,
|
|
branch.Tip?.Author.When,
|
|
branch.Tip?.Sha);
|
|
}
|
|
}
|
|
|
|
public bool DeleteBranch(string repoPath, string branchName)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repoPath);
|
|
ArgumentNullException.ThrowIfNull(branchName);
|
|
|
|
// Clear the cached repository BEFORE deletion to avoid stale state
|
|
if (_repoCache.TryRemove(repoPath, out var cached))
|
|
{
|
|
cached.Repository.Dispose();
|
|
}
|
|
|
|
try
|
|
{
|
|
// Use a fresh repository instance
|
|
using var repo = new Repository(repoPath);
|
|
var branch = repo.Branches[branchName];
|
|
|
|
if (branch == null)
|
|
return false;
|
|
|
|
if (branch.IsCurrentRepositoryHead)
|
|
return false; // Can't delete current branch
|
|
|
|
repo.Branches.Remove(branch);
|
|
|
|
// Verify the branch was actually deleted
|
|
var verifyBranch = repo.Branches[branchName];
|
|
return verifyBranch == null;
|
|
}
|
|
catch (LibGit2SharpException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
|
|
_cleanupTimer.Dispose();
|
|
|
|
foreach (var cached in _repoCache.Values)
|
|
{
|
|
cached.Repository.Dispose();
|
|
}
|
|
_repoCache.Clear();
|
|
_disposed = true;
|
|
}
|
|
}
|