Explorar el Código

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

sujith hace 1 mes
padre
commit
d33cbfb8ae

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

@@ -67,7 +67,6 @@ namespace Terra.Arbitrator.GUI
 
             EditorGUILayout.Space();
 
-            // --- Action Buttons ---
             DrawActionButtons();
         }
 
@@ -79,7 +78,6 @@ namespace Terra.Arbitrator.GUI
             EditorGUILayout.BeginHorizontal();
             GUILayout.FlexibleSpace();
 
-            // Reset Button
             EditorGUI.BeginDisabledGroup(!canReset);
             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)))
             {
-                // Signal "Pull Anyway" by invoking the callback with an empty, non-null list.
                 _callbackInvoked = true;
                 _onCloseCallback?.Invoke(new List<string>());
                 Close();
             }
             
-            // Cancel Button
             if (GUILayout.Button("Cancel", GUILayout.Height(30)))
             {
                 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.
 //
-// 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.Settings;
 using System.Collections.Generic;
-using System.Runtime.InteropServices;
-using Debug = UnityEngine.Debug;
 
 namespace Terra.Arbitrator.Services
 {
+    /// <summary>
+    /// The public static API for interacting with the Git repository.
+    /// </summary>
     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>
-        /// 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>
-        public static IPromise<bool> UnstageAllFilesIfSafe()
+        public static GitChange GetChangeForFile(string filePath)
         {
-            return new Promise<bool>(UnstageAllFilesIfSafeExecutor);
+            return GitExecutors.GetChangeForFile(filePath);
         }
         
         /// <summary>
-        /// Creates a promise to discard all local changes (tracked and untracked).
+        /// Compares the local repository state to the tracked remote branch.
         /// </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>
-        /// 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>
-        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>
-        /// 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>
-        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>
-        /// 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>
-        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>
-        /// The private logic for the CommitAndPush promise.
-        /// This code is executed on a background thread.
+        /// Analyzes if a pull operation results in conflicts.
         /// </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>
-        /// 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>
-        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>
-        /// The private logic for launching a merge tool for a conflicted file.
+        /// Performs a pull that may result in merge conflicts.
         /// </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>
-        /// Executor for UnstageAllFilesIfSafe. Checks for conflicts and staged files, then unstages if appropriate.
+        /// Launches an external merge tool for a conflicted file.
         /// </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>
-        /// Executor for ResetAllChanges. Discards all local changes.
+        /// Unstages all files if the repository is in a clean, non-conflicted state.
         /// </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>
-        /// Searches for an executable in common macOS PATH directories.
+        /// Discards all local changes (tracked and untracked) in the repository.
         /// </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");
     }
-}
+}