Files
gitcommiteditor/Services/GitOperationsService.cs
2025-12-28 05:38:14 -05:00

1001 lines
34 KiB
C#

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;
}
}