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 _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(); } } } /// /// Normalizes a path for consistent cache key comparison. /// On Windows, paths are case-insensitive, so we normalize to lowercase. /// 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; } /// /// 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.) /// public void InvalidateCache(string path) { var normalizedPath = NormalizePath(path); if (_repoCache.TryRemove(normalizedPath, out var cached)) { cached.Repository.Dispose(); } } public IEnumerable 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 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 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(parent?.Tree, commit.Tree); var patch = repo.Diff.Compare(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(); 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(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(); 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 RewordMultipleCommits(ManagedRepo managedRepo, Dictionary rewrites) { ArgumentNullException.ThrowIfNull(managedRepo); ArgumentNullException.ThrowIfNull(rewrites); var operations = new List(); 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(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(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(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(); 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(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(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(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(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 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; } }