// Copyright (c) 2025 TerraByte Inc. // // A new internal class that contains the concrete implementation logic for // all Git operations. This separates the "how" from the "what" defined // in the public GitService API. using System; using System.IO; using System.Linq; using UnityEngine; using LibGit2Sharp; using System.Text; using System.Globalization; using System.ComponentModel; using UnityEngine.Scripting; using System.Threading.Tasks; using Terra.Arbitrator.Settings; using System.Collections.Generic; namespace Terra.Arbitrator.Services { /// /// A simple data container for branch information. /// [Preserve] public class BranchData { public string CurrentBranch { get; set; } public List AllBranches { get; set; } } /// /// Contains the promise executor methods for all Git operations. /// This is an internal implementation detail and is not exposed publicly. /// [Preserve] internal static class GitExecutors { private static string _projectRoot; private static string ProjectRoot => _projectRoot ??= MainThreadDataCache.ProjectRoot; public static void GetBranchDataExecutor(Action resolve, Action reject) { try { using var repo = new Repository(ProjectRoot); var data = new BranchData { CurrentBranch = repo.Head.FriendlyName, AllBranches = repo.Branches .Where(b => !b.FriendlyName.Contains("HEAD")) .Select(b => b.FriendlyName.Replace("origin/", "")) .Distinct() .OrderBy(name => name) .ToList() }; resolve(data); } catch(Exception ex) { reject(ex); } } public static async void SwitchBranchExecutor(Action resolve, Action reject, string branchName) { try { var log = new StringBuilder(); await GitCommand.RunGitAsync(log, new[] { "checkout", branchName }); resolve($"Successfully switched to branch '{branchName}'."); } catch (Exception ex) { reject(ex); } } public static async void ResetAndSwitchBranchExecutor(Action resolve, Action reject, string branchName) { try { var log = new StringBuilder(); await GitCommand.RunGitAsync(log, new[] { "reset", "--hard", "HEAD" }); await GitCommand.RunGitAsync(log, new[] { "clean", "-fd" }); await GitCommand.RunGitAsync(log, new[] { "checkout", branchName }); resolve($"Discarded local changes and switched to branch '{branchName}'."); } catch (Exception ex) { reject(ex); } } /// /// Synchronous helper to get a GitChange object for a single file. /// This is public so it can be called by the GitService wrapper. /// public static GitChange GetChangeForFile(string filePath) { try { using var repo = new Repository(ProjectRoot); if (repo.Index.Conflicts.Any(c => c.Ours.Path == filePath)) { return new GitChange(filePath, null, ChangeKind.Conflicted); } var statusEntry = repo.RetrieveStatus(filePath); return statusEntry switch { FileStatus.NewInWorkdir or FileStatus.NewInIndex => new GitChange(filePath, null, ChangeKind.Added), FileStatus.ModifiedInWorkdir or FileStatus.ModifiedInIndex => new GitChange(filePath, null, ChangeKind.Modified), FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => new GitChange(filePath, null, ChangeKind.Deleted), FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex => new GitChange(filePath, null, ChangeKind.Renamed), _ => null }; } catch { return null; } // Suppress errors if repo is in a unique state } // --- Promise Executor Implementations --- public static async void GetUpstreamAheadByExecutor(Action resolve, Action reject, Action onProgress) { try { var progressReporter = new Progress(line => ParseProgress(line, onProgress)); await GitCommand.RunGitAsync(new StringBuilder(), new[] { "fetch", "--progress" }, progressReporter); using var repo = new Repository(ProjectRoot); resolve(repo.Head.TrackingDetails.BehindBy); } catch (Exception ex) { if (ex.Message.Contains("is not tracking a remote branch")) { resolve(null); } else { reject(ex); } } } public static void GetLocalStatusExecutor(Action> resolve, Action reject) { try { var changes = new List(); using var repo = new Repository(ProjectRoot); var conflictedPaths = new HashSet(repo.Index.Conflicts.Select(c => c.Ours.Path)); var statusOptions = new StatusOptions { IncludeUntracked = true, RecurseUntrackedDirs = true, DetectRenamesInIndex = true, DetectRenamesInWorkDir = true }; foreach (var entry in repo.RetrieveStatus(statusOptions)) { if (conflictedPaths.Contains(entry.FilePath)) { if (changes.All(c => c.FilePath != entry.FilePath)) { changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Conflicted)); } continue; } switch(entry.State) { case FileStatus.NewInWorkdir: case FileStatus.NewInIndex: changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Added)); break; case FileStatus.ModifiedInWorkdir: case FileStatus.ModifiedInIndex: changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Modified)); break; case FileStatus.DeletedFromWorkdir: case FileStatus.DeletedFromIndex: changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Deleted)); break; case FileStatus.RenamedInWorkdir: case FileStatus.RenamedInIndex: var renameDetails = entry.HeadToIndexRenameDetails ?? entry.IndexToWorkDirRenameDetails; changes.Add(renameDetails != null ? new GitChange(renameDetails.NewFilePath, renameDetails.OldFilePath, ChangeKind.Renamed) : new GitChange(entry.FilePath, "Unknown", ChangeKind.Renamed)); break; } } resolve(changes); } catch (Exception ex) { reject(ex); } } public static async void CommitAndPushExecutor(Action resolve, Action reject, List changesToCommit, string commitMessage, string username, string email, Action onProgress) { try { if (string.IsNullOrWhiteSpace(email)) { throw new Exception("Author email is missing. Please set your email address in Project Settings > Better Git."); } using (var repo = new Repository(ProjectRoot)) { var remote = repo.Network.Remotes["origin"]; if (remote == null) throw new Exception("No remote named 'origin' found."); var fetchOptions = new FetchOptions { CertificateCheck = (_, _, _) => true }; Commands.Fetch(repo, remote.Name, Array.Empty(), fetchOptions, "Arbitrator pre-push fetch"); var trackingDetails = repo.Head.TrackingDetails; if (trackingDetails.BehindBy > 0) { throw new Exception($"Push aborted. There are {trackingDetails.BehindBy.Value} incoming changes on the remote. Please pull first."); } var pathsToStage = new List(); foreach (var change in changesToCommit) { switch (change.Status) { case ChangeKind.Deleted: Commands.Remove(repo, change.FilePath); break; case ChangeKind.Renamed: Commands.Remove(repo, change.OldFilePath); pathsToStage.Add(change.FilePath); break; default: pathsToStage.Add(change.FilePath); break; } } if (pathsToStage.Any()) Commands.Stage(repo, pathsToStage); var status = repo.RetrieveStatus(); if (!status.IsDirty) throw new Exception("No effective changes were staged to commit."); var author = new Signature(username, email, DateTimeOffset.Now); repo.Commit(commitMessage, author, author); } var progressReporter = new Progress(line => ParseProgress(line, onProgress)); await GitCommand.RunGitAsync(new StringBuilder(), new[] { "push", "--progress" }, progressReporter, 0, 141); resolve("Successfully committed and pushed changes!"); } catch (Exception ex) { var errorMessage = ex.InnerException?.Message ?? ex.Message; reject(new Exception(errorMessage)); } } private static void ParseProgress(string line, Action onProgress) { if (onProgress == null || string.IsNullOrWhiteSpace(line)) return; line = line.Trim(); var parts = line.Split(new[] { ':' }, 2); if (parts.Length < 2) return; var action = parts[0]; var progressPart = parts[1]; var percentIndex = progressPart.IndexOf('%'); if (percentIndex == -1) return; var percentString = progressPart[..percentIndex].Trim(); if (!float.TryParse(percentString, NumberStyles.Any, CultureInfo.InvariantCulture, out var percentage)) return; var progressValue = percentage / 100.0f; onProgress(progressValue, $"{action}..."); } public static void ResetFileExecutor(Action resolve, Action reject, GitChange changeToReset) { try { using var repo = new Repository(ProjectRoot); switch (changeToReset.Status) { case ChangeKind.Added: { Commands.Unstage(repo, changeToReset.FilePath); var fullPath = Path.Combine(ProjectRoot, changeToReset.FilePath); if (File.Exists(fullPath)) { File.Delete(fullPath); } break; } case ChangeKind.Renamed: { Commands.Unstage(repo, changeToReset.FilePath); var newFullPath = Path.Combine(ProjectRoot, changeToReset.FilePath); if (File.Exists(newFullPath)) File.Delete(newFullPath); repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.OldFilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); break; } default: repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.FilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); break; } resolve($"Successfully reset changes for '{changeToReset.FilePath}'"); } catch (Exception ex) { reject(ex); } } public static async void LaunchExternalDiffExecutor(Action resolve, Action reject, GitChange change) { string fileAPath = null; // Before string fileBPath = null; // After try { using var repo = new Repository(ProjectRoot); string GetFileContentFromHead(string path) { var blob = repo.Head.Tip[path]?.Target as Blob; return blob?.GetContentText() ?? ""; } string CreateTempFile(string originalPath, string content) { var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(originalPath)); File.WriteAllText(tempPath, content); return tempPath; } switch (change.Status) { case ChangeKind.Added: fileAPath = CreateTempFile(change.FilePath, ""); fileBPath = Path.Combine(ProjectRoot, change.FilePath); break; case ChangeKind.Deleted: fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath)); fileBPath = CreateTempFile(change.FilePath, ""); break; case ChangeKind.Renamed: fileAPath = CreateTempFile(change.OldFilePath, GetFileContentFromHead(change.OldFilePath)); fileBPath = Path.Combine(ProjectRoot, change.FilePath); break; default: // Modified fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath)); fileBPath = Path.Combine(ProjectRoot, change.FilePath); break; } await GitCommand.RunVsCodeAsync(new StringBuilder(), new[] { "--diff", fileAPath, fileBPath }); resolve("Launched external diff tool."); } catch(Win32Exception ex) { reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex)); } catch(Exception ex) { reject(ex); } finally { try { if (fileAPath != null && fileAPath.Contains(Path.GetTempPath()) && File.Exists(fileAPath)) File.Delete(fileAPath); if (fileBPath != null && fileBPath.Contains(Path.GetTempPath()) && File.Exists(fileBPath)) File.Delete(fileBPath); } catch(Exception cleanupEx) { Debug.LogError($"Failed to clean up temporary diff files: {cleanupEx.Message}"); } } } public static void FileLevelConflictCheckExecutor(Action resolve, Action reject) { try { using var repo = new Repository(ProjectRoot); var result = AnalyzePullConflictsInternal(repo).Result; resolve(result); } catch (Exception ex) { reject(ex); } } private static Task AnalyzePullConflictsInternal(Repository repo) { var remote = repo.Network.Remotes["origin"]; if (remote == null) throw new Exception("No remote named 'origin' was found."); Commands.Fetch(repo, remote.Name, Array.Empty(), new FetchOptions { CertificateCheck = (_,_,_) => true }, null); var localBranch = repo.Head; var remoteBranch = repo.Head.TrackedBranch; if (remoteBranch == null) throw new Exception("Current branch is not tracking a remote branch."); var mergeBase = repo.ObjectDatabase.FindMergeBase(localBranch.Tip, remoteBranch.Tip); if (mergeBase == null) throw new Exception("Could not find a common ancestor."); var theirChanges = new HashSet(repo.Diff.Compare(mergeBase.Tree, remoteBranch.Tip.Tree).Select(c => c.Path)); var ourChanges = new HashSet(repo.Diff.Compare(mergeBase.Tree, localBranch.Tip.Tree).Select(c => c.Path)); foreach (var statusEntry in repo.RetrieveStatus()) ourChanges.Add(statusEntry.FilePath); return Task.FromResult(new PullAnalysisResult(ourChanges.Where(theirChanges.Contains).ToList())); } public static void SafePullExecutor(Action resolve, Action reject) { try { using var repo = new Repository(ProjectRoot); var signature = new Signature("Better Git Tool", "bettergit@letsterra.com", DateTimeOffset.Now); var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (_,_,_) => true } }; var mergeResult = Commands.Pull(repo, signature, pullOptions); resolve(mergeResult.Status == MergeStatus.UpToDate ? "Already up-to-date." : $"Pull successful. Status: {mergeResult.Status}"); } catch (Exception ex) { reject(ex); } } public static async void ForcePullExecutor(Action resolve, Action reject) { var log = new StringBuilder(); var hasStashed = false; try { using (var repo = new Repository(ProjectRoot)) { if (repo.RetrieveStatus().IsDirty) { await GitCommand.RunGitAsync(log, new[] { "stash", "push", "-u", "-m", "BetterGit-WIP-Pull" }, 0, 141); hasStashed = true; } } await GitCommand.RunGitAsync(log, new[] { "pull", "--no-rebase" }, 0, 1, 141); if (hasStashed) { await GitCommand.RunGitAsync(log, new[] { "stash", "pop" }, 0, 1, 141); await GitCommand.RunGitAsync(log, new[] { "stash", "drop" }, 0, 141); } resolve(log.ToString()); } catch (Exception ex) { if (hasStashed) { try { await GitCommand.RunGitAsync(new StringBuilder(), new[] { "stash", "pop" }, 0, 1, 141); } catch (Exception exception) { log.AppendLine($"Fatal Error trying to pop stash after a failed pull: {exception.Message}"); } } log.AppendLine("\n--- PULL FAILED ---"); log.AppendLine(ex.ToString()); reject(new Exception(log.ToString())); } } public static async void LaunchMergeToolExecutor(Action resolve, Action reject, GitChange change) { try { if (change.FilePath == null) { reject(new Exception("Could not find file path.")); return; } var fileExtension = Path.GetExtension(change.FilePath).ToLower(); if (fileExtension is ".prefab" or ".unity") { reject(new Exception("Cannot auto-resolve conflicts for binary files. Please use an external merge tool.")); return; } await GitCommand.RunVsCodeAsync(new StringBuilder(), new[] { "--wait", change.FilePath }, 0, 141); var fullPath = Path.Combine(ProjectRoot, change.FilePath); var fileContent = await File.ReadAllTextAsync(fullPath); if (fileContent.Contains("<<<<<<<")) { resolve($"Conflict in '{change.FilePath}' was not resolved. Please try again."); return; } await GitCommand.RunGitAsync(new StringBuilder(), new[] { "add", change.FilePath }); await GitCommand.RunGitAsync(new StringBuilder(), new[] { "reset", "HEAD", change.FilePath }); resolve($"Successfully resolved conflict in '{change.FilePath}'. The file is now modified and ready for review."); } catch (Win32Exception ex) { reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex)); } catch (Exception ex) { reject(ex); } } public static async void UnstageAllFilesIfSafeExecutor(Action resolve, Action reject) { try { using var repo = new Repository(ProjectRoot); if (repo.Index.Conflicts.Any()) { resolve(false); return; } var stagedFiles = repo.RetrieveStatus().Count(s => s.State is FileStatus.NewInIndex or FileStatus.ModifiedInIndex or FileStatus.DeletedFromIndex or FileStatus.RenamedInIndex or FileStatus.TypeChangeInIndex); if (stagedFiles == 0) { resolve(false); return; } await GitCommand.RunGitAsync(new StringBuilder(), new[] { "reset" }); resolve(true); } catch (Exception ex) { reject(ex); } } public static async void ResetAllChangesExecutor(Action resolve, Action reject) { try { var log = new StringBuilder(); await GitCommand.RunGitAsync(log, new[] { "reset", "--hard", "HEAD" }); await GitCommand.RunGitAsync(log, new[] { "clean", "-fd" }); resolve("Successfully discarded all local changes."); } catch (Exception ex) { reject(ex); } } } }