浏览代码

Sujith :) ->
1. Refactored git service into multiple scripts.

sujith 1 月之前
父节点
当前提交
d33cbfb8ae

+ 0 - 4
Assets/Arbitrator/Editor/GUI/ConflictResolutionWindow.cs

@@ -67,7 +67,6 @@ namespace Terra.Arbitrator.GUI
 
 
             EditorGUILayout.Space();
             EditorGUILayout.Space();
 
 
-            // --- Action Buttons ---
             DrawActionButtons();
             DrawActionButtons();
         }
         }
 
 
@@ -79,7 +78,6 @@ namespace Terra.Arbitrator.GUI
             EditorGUILayout.BeginHorizontal();
             EditorGUILayout.BeginHorizontal();
             GUILayout.FlexibleSpace();
             GUILayout.FlexibleSpace();
 
 
-            // Reset Button
             EditorGUI.BeginDisabledGroup(!canReset);
             EditorGUI.BeginDisabledGroup(!canReset);
             if (GUILayout.Button($"Reset {selectedCount} Selected File(s)", GUILayout.Height(30), GUILayout.Width(200)))
             if (GUILayout.Button($"Reset {selectedCount} Selected File(s)", GUILayout.Height(30), GUILayout.Width(200)))
             {
             {
@@ -99,13 +97,11 @@ namespace Terra.Arbitrator.GUI
 
 
             if (GUILayout.Button(new GUIContent("Pull Anyway", "This will perform the pull, leaving merge conflicts in your local files for you to resolve."), GUILayout.Height(30)))
             if (GUILayout.Button(new GUIContent("Pull Anyway", "This will perform the pull, leaving merge conflicts in your local files for you to resolve."), GUILayout.Height(30)))
             {
             {
-                // Signal "Pull Anyway" by invoking the callback with an empty, non-null list.
                 _callbackInvoked = true;
                 _callbackInvoked = true;
                 _onCloseCallback?.Invoke(new List<string>());
                 _onCloseCallback?.Invoke(new List<string>());
                 Close();
                 Close();
             }
             }
             
             
-            // Cancel Button
             if (GUILayout.Button("Cancel", GUILayout.Height(30)))
             if (GUILayout.Button("Cancel", GUILayout.Height(30)))
             {
             {
                 Close();
                 Close();

+ 89 - 0
Assets/Arbitrator/Editor/Services/GitCommand.cs

@@ -0,0 +1,89 @@
+// Copyright (c) 2025 TerraByte Inc.
+//
+// A new helper class that abstracts away the boilerplate of running external
+// command-line processes, specifically Git and VS Code, using CliWrap.
+
+using System;
+using CliWrap;
+using System.IO;
+using System.Linq;
+using UnityEngine;
+using System.Text;
+using System.Threading.Tasks;
+using System.Runtime.InteropServices;
+
+namespace Terra.Arbitrator.Services
+{
+    /// <summary>
+    /// An internal helper class for executing Git commands.
+    /// It centralizes the logic for finding executables and running them via CliWrap.
+    /// </summary>
+    internal static class GitCommand
+    {
+        private static string _projectRoot;
+        private static string ProjectRoot => _projectRoot ??= Directory.GetParent(Application.dataPath)?.FullName;
+
+        /// <summary>
+        /// Runs a git command asynchronously.
+        /// </summary>
+        /// <param name="log">A StringBuilder to capture command output for logging.</param>
+        /// <param name="args">The arguments to pass to the git command.</param>
+        /// <param name="acceptableExitCodes">A list of exit codes that should not be treated as errors.</param>
+        public static async Task RunAsync(StringBuilder log, string[] args, params int[] acceptableExitCodes)
+        {
+            var stdOutBuffer = new StringBuilder();
+            var stdErrBuffer = new StringBuilder();
+            var argumentsString = string.Join(" ", args);
+            log?.AppendLine($"\n--- Executing: git {argumentsString} ---");
+
+            var command = Cli.Wrap(FindGitExecutable())
+                             .WithArguments(args)
+                             .WithWorkingDirectory(ProjectRoot)
+                             .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}");
+
+            // Default to 0 if no specific codes are provided
+            if (acceptableExitCodes.Length == 0)
+            {
+                acceptableExitCodes = new[] { 0 };
+            }
+
+            if (!acceptableExitCodes.Contains(result.ExitCode))
+            {
+                throw new Exception($"Command 'git {argumentsString}' failed with unexpected exit code {result.ExitCode}. Error: {stdErrBuffer}");
+            }
+        }
+        
+        /// <summary>
+        /// Finds the absolute path to a given executable.
+        /// </summary>
+        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.");
+        }
+        
+        public static string FindVsCodeExecutable() => FindExecutable("code");
+        private static string FindGitExecutable() => FindExecutable("git");
+    }
+}

+ 3 - 0
Assets/Arbitrator/Editor/Services/GitCommand.cs.meta

@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: d11e546cfcd14d9ab0466caa8f6bb3f8
+timeCreated: 1750753091

+ 470 - 0
Assets/Arbitrator/Editor/Services/GitExecutors.cs

@@ -0,0 +1,470 @@
+// 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 CliWrap;
+using System.IO;
+using System.Linq;
+using UnityEngine;
+using LibGit2Sharp;
+using System.Text;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Terra.Arbitrator.Settings;
+using System.Collections.Generic;
+
+namespace Terra.Arbitrator.Services
+{
+    /// <summary>
+    /// Contains the promise executor methods for all Git operations.
+    /// This is an internal implementation detail and is not exposed publicly.
+    /// </summary>
+    internal static class GitExecutors
+    {
+        private static string _projectRoot;
+        private static string ProjectRoot => _projectRoot ??= Directory.GetParent(Application.dataPath)?.FullName;
+        
+        /// <summary>
+        /// Synchronous helper to get a GitChange object for a single file.
+        /// This is public so it can be called by the GitService wrapper.
+        /// </summary>
+        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 void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                var changes = new List<GitChange>();
+                using var repo = new Repository(ProjectRoot);
+                
+                var conflictedPaths = new HashSet<string>(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<string> resolve, Action<Exception> reject, List<GitChange> 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.");
+                }
+                
+                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<string>(), 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<string>();
+                    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);
+                }
+
+                await GitCommand.RunAsync(new StringBuilder(), new []{ "push" }, 0, 141);
+
+                resolve("Successfully committed and pushed changes!");
+            }
+            catch (Exception ex)
+            {
+                var errorMessage = ex.InnerException?.Message ?? ex.Message;
+                reject(new Exception(errorMessage));
+            }
+        }
+
+        public static void ResetFileExecutor(Action<string> resolve, Action<Exception> reject, GitChange changeToReset)
+        {
+            try
+            {
+                using var repo = new Repository(ProjectRoot);
+
+                if (changeToReset.Status == ChangeKind.Added)
+                {
+                    Commands.Unstage(repo, changeToReset.FilePath);
+                    
+                    var fullPath = Path.Combine(ProjectRoot, changeToReset.FilePath);
+                    if (File.Exists(fullPath))
+                    {
+                        File.Delete(fullPath);
+                    }
+                }
+                else if (changeToReset.Status == 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 });
+                }
+                else
+                {
+                    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);
+            }
+        }
+        
+        public static async void LaunchExternalDiffExecutor(Action<string> resolve, Action<Exception> 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 Cli.Wrap(GitCommand.FindVsCodeExecutable())
+                    .WithArguments(args => args.Add("--diff").Add(fileAPath).Add(fileBPath))
+                    .ExecuteAsync();
+
+                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<PullAnalysisResult> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                using var repo = new Repository(ProjectRoot);
+                var result = AnalyzePullConflictsInternal(repo).Result; 
+                resolve(result);
+            }
+            catch (Exception ex)
+            {
+                reject(ex);
+            }
+        }
+
+        private static Task<PullAnalysisResult> 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<string>(), 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<string>(repo.Diff.Compare<TreeChanges>(mergeBase.Tree, remoteBranch.Tip.Tree).Select(c => c.Path));
+            var ourChanges = new HashSet<string>(repo.Diff.Compare<TreeChanges>(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<string> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                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 = (_,_,_) => 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<string> resolve, Action<Exception> reject)
+        {
+            var log = new StringBuilder();
+            var hasStashed = false;
+
+            try
+            {
+                using (var repo = new Repository(ProjectRoot))
+                {
+                    if (repo.RetrieveStatus().IsDirty)
+                    {
+                        await GitCommand.RunAsync(log, new[] { "stash", "push", "-u", "-m", "BetterGit-WIP-Pull" }, 0, 141);
+                        hasStashed = true;
+                    }
+                }
+                
+                await GitCommand.RunAsync(log, new[] { "pull", "--no-rebase" }, 0, 1, 141);
+
+                if (hasStashed)
+                {
+                    await GitCommand.RunAsync(log, new[] { "stash", "pop" }, 0, 1, 141);
+                }
+                
+                resolve(log.ToString());
+            }
+            catch (Exception ex)
+            {
+                if (hasStashed)
+                {
+                    try
+                    {
+                        await GitCommand.RunAsync(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<string> resolve, Action<Exception> 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 Cli.Wrap(GitCommand.FindVsCodeExecutable())
+                    .WithArguments(args => args.Add("--wait").Add(change.FilePath))
+                    .WithWorkingDirectory(ProjectRoot)
+                    .ExecuteAsync();
+                
+                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.RunAsync(new StringBuilder(), new[] { "add", change.FilePath });
+                await GitCommand.RunAsync(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<bool> resolve, Action<Exception> 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.RunAsync(new StringBuilder(), new[] { "reset" });
+                resolve(true);
+            }
+            catch (Exception ex)
+            {
+                reject(ex);
+            }
+        }
+        
+        public static async void ResetAllChangesExecutor(Action<string> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                var log = new StringBuilder();
+                await GitCommand.RunAsync(log, new[] { "reset", "--hard", "HEAD" });
+                await GitCommand.RunAsync(log, new[] { "clean", "-fd" });
+
+                resolve("Successfully discarded all local changes.");
+            }
+            catch (Exception ex)
+            {
+                reject(ex);
+            }
+        }
+    }
+}

+ 3 - 0
Assets/Arbitrator/Editor/Services/GitExecutors.cs.meta

@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: adf450ca5cab4271b655e1807675cf1b
+timeCreated: 1750753184

+ 46 - 705
Assets/Arbitrator/Editor/Services/GitService.cs

@@ -1,768 +1,109 @@
 // Copyright (c) 2025 TerraByte Inc.
 // 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.
+// This script serves as the clean, public-facing API for all Git operations.
+// It creates promises and delegates the actual implementation work to the
+// internal GitExecutors class.
 
 
-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 Terra.Arbitrator.Promises;
+using Terra.Arbitrator.Settings;
 using System.Collections.Generic;
 using System.Collections.Generic;
-using System.Runtime.InteropServices;
-using Debug = UnityEngine.Debug;
 
 
 namespace Terra.Arbitrator.Services
 namespace Terra.Arbitrator.Services
 {
 {
+    /// <summary>
+    /// The public static API for interacting with the Git repository.
+    /// </summary>
     public static class GitService
     public static class GitService
     {
     {
-        // Public method that returns the promise
-        public static IPromise<List<GitChange>> CompareLocalToRemote()
-        {
-            return new Promise<List<GitChange>>(GetLocalStatusExecutor);
-        }
-        
-        // Public method that returns the promise
-        public static IPromise<string> CommitAndPush(List<GitChange> changesToCommit, string commitMessage, string username, string email)
-        {
-            // Use a lambda here to pass arguments to the executor method
-            return new Promise<string>((resolve, reject) => 
-                CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage, username, email));
-        }
-        
-        // Creates a promise to revert a single file to its HEAD revision.
-        public static IPromise<string> ResetFileChanges(GitChange changeToReset)
-        {
-            return new Promise<string>((resolve, reject) => 
-                ResetFileExecutor(resolve, reject, changeToReset));
-        }
-
-        // Creates a promise to launch an external diff tool (VS Code).
-        public static IPromise<string> LaunchExternalDiff(GitChange change)
-        {
-            return new Promise<string>((resolve, reject) => LaunchExternalDiffExecutor(resolve, reject, change));
-        }
-        
-        // Performs a non-destructive analysis of a potential pull.
-        public static IPromise<PullAnalysisResult> AnalyzePullConflicts()
-        {
-            return new Promise<PullAnalysisResult>(FileLevelConflictCheckExecutor);
-        }
-
-        // Performs a pull, assuming analysis has already confirmed it's safe.
-        public static IPromise<string> PerformSafePull()
-        {
-            return new Promise<string>(SafePullExecutor);
-        }
-
-        // Creates a promise to perform a pull that may result in conflicts.
-        public static IPromise<string> ForcePull()
-        {
-            return new Promise<string>(ForcePullExecutor);
-        }
-        
-        // Creates a promise to launch an external merge tool for a conflicted file.
-        public static IPromise<string> LaunchMergeTool(GitChange change)
-        {
-            return new Promise<string>((resolve, reject) => LaunchMergeToolExecutor(resolve, reject, change));
-        }
-        
         /// <summary>
         /// <summary>
-        /// Creates a promise to unstage all files if the repository is in a clean, non-conflicted state.
+        /// Synchronously gets the Git status for a single file.
+        /// Required by UI elements that cannot easily use promises.
         /// </summary>
         /// </summary>
-        public static IPromise<bool> UnstageAllFilesIfSafe()
+        public static GitChange GetChangeForFile(string filePath)
         {
         {
-            return new Promise<bool>(UnstageAllFilesIfSafeExecutor);
+            return GitExecutors.GetChangeForFile(filePath);
         }
         }
         
         
         /// <summary>
         /// <summary>
-        /// Creates a promise to discard all local changes (tracked and untracked).
+        /// Compares the local repository state to the tracked remote branch.
         /// </summary>
         /// </summary>
-        public static IPromise<string> ResetAllChanges()
+        public static IPromise<List<GitChange>> CompareLocalToRemote()
         {
         {
-            return new Promise<string>(ResetAllChangesExecutor);
+            return new Promise<List<GitChange>>(GitExecutors.GetLocalStatusExecutor);
         }
         }
         
         
         /// <summary>
         /// <summary>
-        /// Synchronous helper to get a GitChange object for a single file.
-        /// Needed by the static persistence script which cannot easily use promises.
+        /// Commits and pushes the selected files to the remote repository.
         /// </summary>
         /// </summary>
-        public static GitChange GetChangeForFile(string filePath)
+        public static IPromise<string> CommitAndPush(List<GitChange> changesToCommit, string commitMessage, string username, string email)
         {
         {
-            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
+            return new Promise<string>((resolve, reject) => 
+                GitExecutors.CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage, username, email));
         }
         }
         
         
         /// <summary>
         /// <summary>
-        /// Gets the status of the local repository, showing unstaged changes.
-        /// This is the equivalent of 'git status'.
+        /// Reverts all local changes for a single file.
         /// </summary>
         /// </summary>
-        private static void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
-        {
-            try
-            {
-                var changes = new List<GitChange>();
-                var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
-                using var repo = new Repository(projectRoot);
-                
-                var conflictedPaths = new HashSet<string>(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<PullAnalysisResult> resolve, Action<Exception> 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<PullAnalysisResult> 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<string>(), 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<string>();
-            var remoteDiff = repo.Diff.Compare<TreeChanges>(mergeBase.Tree, remoteBranch.Tip.Tree);
-            foreach (var change in remoteDiff) theirChanges.Add(change.Path);
-            
-            var ourChanges = new HashSet<string>();
-            var localCommitDiff = repo.Diff.Compare<TreeChanges>(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<string> resolve, Action<Exception> 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<string> resolve, Action<Exception> reject)
+        public static IPromise<string> ResetFileChanges(GitChange changeToReset)
         {
         {
-            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);
-            }
+            return new Promise<string>((resolve, reject) => 
+                GitExecutors.ResetFileExecutor(resolve, reject, changeToReset));
         }
         }
-        
-        private static async void ForcePullExecutor(Action<string> resolve, Action<Exception> 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()));
-            }
-        }
-        
         /// <summary>
         /// <summary>
-        /// A reusable helper method to execute Git commands with detailed logging and exit code validation.
+        /// Launches an external diff tool to compare file versions.
         /// </summary>
         /// </summary>
-        private static async Task ExecuteGitCommandAsync(string args, string workingDir, StringBuilder log, params int[] acceptableExitCodes)
+        public static IPromise<string> LaunchExternalDiff(GitChange change)
         {
         {
-            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}.");
-            }
+            return new Promise<string>((resolve, reject) => GitExecutors.LaunchExternalDiffExecutor(resolve, reject, change));
         }
         }
-
+        
         /// <summary>
         /// <summary>
-        /// The private logic for the CommitAndPush promise.
-        /// This code is executed on a background thread.
+        /// Analyzes if a pull operation results in conflicts.
         /// </summary>
         /// </summary>
-        private static async void CommitAndPushExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changesToCommit, string commitMessage, string username, string email)
+        public static IPromise<PullAnalysisResult> AnalyzePullConflicts()
         {
         {
-            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<string>(), 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<string>();
-                    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));
-            }
+            return new Promise<PullAnalysisResult>(GitExecutors.FileLevelConflictCheckExecutor);
         }
         }
 
 
         /// <summary>
         /// <summary>
-        /// The private logic for resetting a file's changes.
-        /// This is executed on a background thread.
+        /// Performs a "safe" pull, assuming no conflicts will occur.
         /// </summary>
         /// </summary>
-        private static void ResetFileExecutor(Action<string> resolve, Action<Exception> reject, GitChange changeToReset)
+        public static IPromise<string> PerformSafePull()
         {
         {
-            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);
-            }
+            return new Promise<string>(GitExecutors.SafePullExecutor);
         }
         }
-        
-        private static async void LaunchExternalDiffExecutor(Action<string> resolve, Action<Exception> 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}");
-                }
-            }
-        }
-        
         /// <summary>
         /// <summary>
-        /// The private logic for launching a merge tool for a conflicted file.
+        /// Performs a pull that may result in merge conflicts.
         /// </summary>
         /// </summary>
-        private static async void LaunchMergeToolExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
+        public static IPromise<string> ForcePull()
         {
         {
-            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);
-            }
+            return new Promise<string>(GitExecutors.ForcePullExecutor);
         }
         }
         
         
         /// <summary>
         /// <summary>
-        /// Executor for UnstageAllFilesIfSafe. Checks for conflicts and staged files, then unstages if appropriate.
+        /// Launches an external merge tool for a conflicted file.
         /// </summary>
         /// </summary>
-        private static async void UnstageAllFilesIfSafeExecutor(Action<bool> resolve, Action<Exception> reject)
+        public static IPromise<string> LaunchMergeTool(GitChange change)
         {
         {
-            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);
-            }
+            return new Promise<string>((resolve, reject) => GitExecutors.LaunchMergeToolExecutor(resolve, reject, change));
         }
         }
-        
+
         /// <summary>
         /// <summary>
-        /// Executor for ResetAllChanges. Discards all local changes.
+        /// Unstages all files if the repository is in a clean, non-conflicted state.
         /// </summary>
         /// </summary>
-        private static async void ResetAllChangesExecutor(Action<string> resolve, Action<Exception> reject)
+        public static IPromise<bool> UnstageAllFilesIfSafe()
         {
         {
-            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);
-            }
+            return new Promise<bool>(GitExecutors.UnstageAllFilesIfSafeExecutor);
         }
         }
         
         
         /// <summary>
         /// <summary>
-        /// Searches for an executable in common macOS PATH directories.
+        /// Discards all local changes (tracked and untracked) in the repository.
         /// </summary>
         /// </summary>
-        private static string FindExecutable(string name)
+        public static IPromise<string> ResetAllChanges()
         {
         {
-            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.");
+            return new Promise<string>(GitExecutors.ResetAllChangesExecutor);
         }
         }
-        
-        private static string FindVsCodeExecutable() => FindExecutable("code");
-        private static string FindGitExecutable() => FindExecutable("git");
     }
     }
-}
+}