// Copyright (c) 2025 TerraByte Inc. // // This script contains the core business logic for interacting with the Git // repository. All public methods are asynchronous and return a promise. using System; using CliWrap; using System.IO; using System.Linq; using UnityEngine; using System.Text; using LibGit2Sharp; using System.ComponentModel; using System.Threading.Tasks; using Terra.Arbitrator.Settings; using Terra.Arbitrator.Promises; using System.Collections.Generic; using System.Runtime.InteropServices; using Debug = UnityEngine.Debug; namespace Terra.Arbitrator.Services { public static class GitService { // Public method that returns the promise public static IPromise> CompareLocalToRemote() { return new Promise>(GetLocalStatusExecutor); } // Public method that returns the promise public static IPromise CommitAndPush(List changesToCommit, string commitMessage, string username, string email) { // Use a lambda here to pass arguments to the executor method return new Promise((resolve, reject) => CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage, username, email)); } // Creates a promise to revert a single file to its HEAD revision. public static IPromise ResetFileChanges(GitChange changeToReset) { return new Promise((resolve, reject) => ResetFileExecutor(resolve, reject, changeToReset)); } // Creates a promise to launch an external diff tool (VS Code). public static IPromise LaunchExternalDiff(GitChange change) { return new Promise((resolve, reject) => LaunchExternalDiffExecutor(resolve, reject, change)); } // Performs a non-destructive analysis of a potential pull. public static IPromise AnalyzePullConflicts() { return new Promise(FileLevelConflictCheckExecutor); } // Performs a pull, assuming analysis has already confirmed it's safe. public static IPromise PerformSafePull() { return new Promise(SafePullExecutor); } // Creates a promise to perform a pull that may result in conflicts. public static IPromise ForcePull() { return new Promise(ForcePullExecutor); } // Creates a promise to launch an external merge tool for a conflicted file. public static IPromise LaunchMergeTool(GitChange change) { return new Promise((resolve, reject) => LaunchMergeToolExecutor(resolve, reject, change)); } /// /// Creates a promise to unstage all files if the repository is in a clean, non-conflicted state. /// public static IPromise UnstageAllFilesIfSafe() { return new Promise(UnstageAllFilesIfSafeExecutor); } /// /// Creates a promise to discard all local changes (tracked and untracked). /// public static IPromise ResetAllChanges() { return new Promise(ResetAllChangesExecutor); } /// /// Synchronous helper to get a GitChange object for a single file. /// Needed by the static persistence script which cannot easily use promises. /// public static GitChange GetChangeForFile(string filePath) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; using var repo = new Repository(projectRoot); var statusEntry = repo.RetrieveStatus(filePath); // Determine ChangeKind from FileStatus 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 => // Getting the old path from a single status entry is complex, // for reset purposes, treating it as modified is safe enough. new GitChange(filePath, null, ChangeKind.Renamed), _ => null }; } catch { return null; } // Suppress errors if repo is in a unique state } /// /// Gets the status of the local repository, showing unstaged changes. /// This is the equivalent of 'git status'. /// private static void GetLocalStatusExecutor(Action> resolve, Action reject) { try { var changes = new List(); var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; 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)) { 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) // Fallback for safety, though this path should rarely be hit with correct options. : new GitChange(entry.FilePath, "Unknown", ChangeKind.Renamed)); break; case FileStatus.Nonexistent: case FileStatus.Unaltered: case FileStatus.TypeChangeInIndex: case FileStatus.TypeChangeInWorkdir: case FileStatus.Unreadable: case FileStatus.Ignored: case FileStatus.Conflicted: default: break; } } resolve(changes); } catch (Exception ex) { Debug.LogException(ex); reject(ex); } } private static void FileLevelConflictCheckExecutor(Action resolve, Action reject) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; using var repo = new Repository(projectRoot); var result = AnalyzePullConflicts(repo).Result; // .Result is safe here as it's wrapped in a promise resolve(result); } catch (Exception ex) { reject(ex); } } private static Task AnalyzePullConflicts(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 = (certificate, valid, host) => 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(); var remoteDiff = repo.Diff.Compare(mergeBase.Tree, remoteBranch.Tip.Tree); foreach (var change in remoteDiff) theirChanges.Add(change.Path); var ourChanges = new HashSet(); var localCommitDiff = repo.Diff.Compare(mergeBase.Tree, localBranch.Tip.Tree); foreach (var change in localCommitDiff) ourChanges.Add(change.Path); var localStatus = repo.RetrieveStatus(); foreach (var statusEntry in localStatus) ourChanges.Add(statusEntry.FilePath); var conflictingFiles = ourChanges.Where(theirChanges.Contains).ToList(); return Task.FromResult(new PullAnalysisResult(conflictingFiles)); } private static async void PullExecutor(Action resolve, Action reject) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; using var repo = new Repository(projectRoot); // 1. Analyze for conflicts first. var analysisResult = await AnalyzePullConflicts(repo); if (analysisResult.HasConflicts) { var conflictFiles = string.Join("\n - ", analysisResult.ConflictingFiles); throw new Exception($"Potential conflicts detected in the following files. Please reset or commit your local changes first:\n - {conflictFiles}"); } // 2. If analysis passes, proceed with the pull. var remote = repo.Network.Remotes["origin"]; if (remote == null) throw new Exception("No remote named 'origin' was found."); var signature = new Signature("Better Git Tool", "bettergit@example.com", DateTimeOffset.Now); var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (certificate, valid, host) => 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); } } private static void SafePullExecutor(Action resolve, Action reject) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; using var repo = new Repository(projectRoot); var signature = new Signature("Better Git Tool", "bettergit@example.com", DateTimeOffset.Now); var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (certificate, valid, host) => 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); } } private static async void ForcePullExecutor(Action resolve, Action reject) { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; var hasLocalChanges = false; var log = new StringBuilder(); var hasStashed = false; try { using (var repo = new Repository(projectRoot)) { if (repo.RetrieveStatus().IsDirty) hasLocalChanges = true; } log.AppendLine($"Step 0: Has local changes? {hasLocalChanges}"); if (hasLocalChanges) { await ExecuteGitCommandAsync("stash push -u -m \"BetterGit-WIP-Pull\"", projectRoot, log, 0, 141); hasStashed = true; } // Use the simpler, more robust pull command. await ExecuteGitCommandAsync("pull --no-rebase", projectRoot, log, 0, 1, 141); if (hasStashed) { await ExecuteGitCommandAsync("stash pop", projectRoot, log, 0, 1, 141); hasStashed = false; } resolve(log.ToString()); } catch (Exception ex) { if (hasStashed) { try { await ExecuteGitCommandAsync("stash pop", projectRoot, log, 0, 1, 141); } catch (Exception exception) { log.AppendLine($"Fatal Error: {exception.Message}"); } } log.AppendLine("\n--- FAILED ---"); log.AppendLine(ex.ToString()); reject(new Exception(log.ToString())); } } /// /// A reusable helper method to execute Git commands with detailed logging and exit code validation. /// private static async Task ExecuteGitCommandAsync(string args, string workingDir, StringBuilder log, params int[] acceptableExitCodes) { var stdOutBuffer = new StringBuilder(); var stdErrBuffer = new StringBuilder(); log?.AppendLine($"\n--- Executing: git {args} ---"); var command = Cli.Wrap(FindGitExecutable()) .WithArguments(args) .WithWorkingDirectory(workingDir) .WithValidation(CommandResultValidation.None) // We handle validation manually | (PipeTarget.ToDelegate(x => stdOutBuffer.Append(x)), PipeTarget.ToDelegate(x => stdErrBuffer.Append(x))); var result = await command.ExecuteAsync(); log?.AppendLine($"Exit Code: {result.ExitCode}"); if(stdOutBuffer.Length > 0) log?.AppendLine($"StdOut: {stdOutBuffer}"); if(stdErrBuffer.Length > 0) log?.AppendLine($"StdErr: {stdErrBuffer}"); // If the exit code is not in our list of acceptable codes, it's an error. if (!acceptableExitCodes.Contains(result.ExitCode)) { throw new Exception($"Command 'git {args}' failed with unexpected exit code {result.ExitCode}."); } } /// /// The private logic for the CommitAndPush promise. /// This code is executed on a background thread. /// private static async void CommitAndPushExecutor(Action resolve, Action reject, List changesToCommit, string commitMessage, string username, string email) { try { if (string.IsNullOrWhiteSpace(email)) { throw new Exception("Author email is missing. Please set your email address in Project Settings > Better Git."); } var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; if (projectRoot == null) { throw new Exception("Could not find project root."); } 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) { if (change.Status == ChangeKind.Deleted) Commands.Remove(repo, change.FilePath); else pathsToStage.Add(change.FilePath); } 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 gitExecutable = FindGitExecutable(); var result = await Cli.Wrap(gitExecutable) .WithArguments("push") .WithWorkingDirectory(projectRoot) .WithValidation(CommandResultValidation.None) // Don't throw exceptions automatically .ExecuteAsync(); // Exit code 141 means the command was successful but was terminated by a SIGPIPE signal. This is safe to ignore on macOS/Linux. if (result.ExitCode != 0 && result.ExitCode != 141) { throw new Exception($"Push Failed. Error code: {result.ExitCode}"); } resolve("Successfully committed and pushed changes!"); } catch (Exception ex) { Debug.LogException(ex); var errorMessage = ex.InnerException?.Message ?? ex.Message; reject(new Exception(errorMessage)); } } /// /// The private logic for resetting a file's changes. /// This is executed on a background thread. /// private static void ResetFileExecutor(Action resolve, Action reject, GitChange changeToReset) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; using var repo = new Repository(projectRoot); // For 'Added' files, checking out doesn't remove them. We need to unstage and delete. if (changeToReset.Status == ChangeKind.Added) { // Unstage the file from the index. Commands.Unstage(repo, changeToReset.FilePath); // And delete it from the working directory. if (projectRoot != null) { var fullPath = Path.Combine(projectRoot, changeToReset.FilePath); if (File.Exists(fullPath)) { File.Delete(fullPath); } } } else if (changeToReset.Status == ChangeKind.Renamed) { // 1. Unstage the new path Commands.Unstage(repo, changeToReset.FilePath); // 2. Delete the new file from the working directory if (projectRoot != null) { var newFullPath = Path.Combine(projectRoot, changeToReset.FilePath); if (File.Exists(newFullPath)) File.Delete(newFullPath); } // 3. Checkout the old path from the HEAD to restore it repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.OldFilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } else { // For Modified or Deleted files, CheckoutPaths is the correct command to revert them. repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.FilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); } resolve($"Successfully reset changes for '{changeToReset.FilePath}'"); } catch (Exception ex) { reject(ex); } } private static async void LaunchExternalDiffExecutor(Action resolve, Action reject, GitChange change) { string fileAPath = null; // Before string fileBPath = null; // After try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; if (projectRoot == null) { reject(new Exception("Could not find project root.")); return; } using var repo = new Repository(projectRoot); // Get the content of a file from a specific commit. string GetFileContentFromHead(string path) { var blob = repo.Head.Tip[path]?.Target as Blob; return blob?.GetContentText() ?? ""; } // Create a temporary file with the correct extension for syntax highlighting. 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, ""); // Empty "before" file fileBPath = Path.Combine(projectRoot, change.FilePath); break; case ChangeKind.Deleted: fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath)); fileBPath = CreateTempFile(change.FilePath, ""); // Empty "after" file break; case ChangeKind.Renamed: fileAPath = CreateTempFile(change.OldFilePath, GetFileContentFromHead(change.OldFilePath)); fileBPath = Path.Combine(projectRoot, change.FilePath); break; case ChangeKind.Unmodified: case ChangeKind.Modified: case ChangeKind.Copied: case ChangeKind.Ignored: case ChangeKind.Untracked: case ChangeKind.TypeChanged: case ChangeKind.Unreadable: case ChangeKind.Conflicted: default: // Modified fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath)); fileBPath = Path.Combine(projectRoot, change.FilePath); break; } var vsCodeExecutable = FindVsCodeExecutable(); await Cli.Wrap(vsCodeExecutable) .WithArguments(args => args .Add("--diff") .Add(fileAPath) .Add(fileBPath)) .ExecuteAsync(); resolve("Launched external diff tool."); } catch(Win32Exception ex) { // This specific exception is thrown if the executable is not found. 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 { // We only delete files we created (i.e., not the actual project file). 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) { // Log cleanup error but don't throw, as the primary exception (if any) is more important. Debug.LogError($"Failed to clean up temporary diff files: {cleanupEx.Message}"); } } } /// /// The private logic for launching a merge tool for a conflicted file. /// private static async void LaunchMergeToolExecutor(Action resolve, Action reject, GitChange change) { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; if (projectRoot == null) { reject(new Exception("Could not find project root.")); return; } if (change.FilePath == null) { reject(new Exception("Could not find file path.")); return; } try { var fileExtension = Path.GetExtension(change.FilePath)?.ToLower(); if (fileExtension is ".prefab" or ".unity") { reject(new Exception("Cannot resolve conflicts for binary files (.prefab, .unity) with VS Code. Please use an external merge tool.")); return; } // Launch VS Code and wait for it to close. var vsCodeExecutable = FindVsCodeExecutable(); await Cli.Wrap(vsCodeExecutable) .WithArguments(args => args .Add("--wait") // Crucial flag .Add(change.FilePath)) .WithWorkingDirectory(projectRoot) .ExecuteAsync(); var fullPath = Path.Combine(projectRoot, change.FilePath); var fileContent = await File.ReadAllTextAsync(fullPath); if (fileContent.Contains("<<<<<<<") || fileContent.Contains("=======") || fileContent.Contains(">>>>>>>")) { // The user closed the editor but did not resolve the conflict. resolve($"Conflict in '{change.FilePath}' was not resolved. Please try again."); return; } var gitExecutable = FindGitExecutable(); // The markers are gone, so now we can tell Git the conflict is resolved. await Cli.Wrap(gitExecutable) .WithArguments($"add \"{change.FilePath}\"") .WithWorkingDirectory(projectRoot) .ExecuteAsync(); // The conflict is resolved and staged. Now, unstage the file to give the user control. await Cli.Wrap(gitExecutable) .WithArguments($"reset HEAD \"{change.FilePath}\"") .WithWorkingDirectory(projectRoot) .ExecuteAsync(); 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); } } /// /// Executor for UnstageAllFilesIfSafe. Checks for conflicts and staged files, then unstages if appropriate. /// private static async void UnstageAllFilesIfSafeExecutor(Action resolve, Action reject) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; if (projectRoot == null) { reject(new Exception("Could not find project root.")); return; } using var repo = new Repository(projectRoot); // Safety Check: Do not proceed if there are any conflicts. if (repo.Index.Conflicts.Any()) { resolve(false); return; } // Check if there are any files staged for commit. var stagedFiles = repo.RetrieveStatus().Where(s => s.State is FileStatus.NewInIndex or FileStatus.ModifiedInIndex or FileStatus.DeletedFromIndex or FileStatus.RenamedInIndex or FileStatus.TypeChangeInIndex); if (!stagedFiles.Any()) { resolve(false); // Nothing to do. return; } // If we get here, it's safe to unstage everything. await ExecuteGitCommandAsync("reset", projectRoot, null, 0, 141); resolve(true); // Signal that files were unstaged. } catch (Exception ex) { reject(ex); } } /// /// Executor for ResetAllChanges. Discards all local changes. /// private static async void ResetAllChangesExecutor(Action resolve, Action reject) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; if (projectRoot == null) { reject(new Exception("Could not find project root.")); return; } // 1. Discard all changes to tracked files await ExecuteGitCommandAsync("reset --hard HEAD", projectRoot, null, 0); // 2. Remove all untracked files and directories await ExecuteGitCommandAsync("clean -fd", projectRoot, null, 0); resolve("Successfully discarded all local changes."); } catch (Exception ex) { reject(ex); } } /// /// Searches for an executable in common macOS PATH directories. /// private static string FindExecutable(string name) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // CliWrap handles PATH search on Windows automatically. return name; } // For macOS/Linux, we need to be more explicit due to Unity's sandboxing. string[] searchPaths = { "/usr/local/bin", "/usr/bin", "/bin", "/opt/homebrew/bin" }; foreach (var path in searchPaths) { var fullPath = Path.Combine(path, name); if (File.Exists(fullPath)) { return fullPath; } } throw new FileNotFoundException($"Could not find executable '{name}'. Please ensure it is installed and in your system's PATH."); } private static string FindVsCodeExecutable() => FindExecutable("code"); private static string FindGitExecutable() => FindExecutable("git"); } }