Browse Source

Sujith :) ->
1. Base changes for stash implementation

sujith 1 month ago
parent
commit
adcbc843e4

+ 160 - 3
Assets/Better Git/Arbitrator/Editor/GUI/ArbitratorController.cs

@@ -13,6 +13,7 @@ using Terra.Arbitrator.Services;
 using Terra.Arbitrator.Promises;
 using System.Collections.Generic;
 using UnityEditor.SceneManagement;
+using UnityEngine;
 
 namespace Terra.Arbitrator.GUI
 {
@@ -30,6 +31,7 @@ namespace Terra.Arbitrator.GUI
         public bool IsLoading { get; private set; }
         public string LoadingMessage { get; private set; } = "";
         public bool IsInConflictState { get; private set; }
+        public bool HasStash { get; private set; }
         public int CommitsToPull { get; private set; }
         public SortColumn CurrentSortColumn { get; private set; } = SortColumn.FilePath;
         public float OperationProgress { get; private set; }
@@ -67,6 +69,7 @@ namespace Terra.Arbitrator.GUI
             UnstageStep()
                 .Then(CompareStep)
                 .Then(FetchUpstreamStep)
+                .Then(CheckForStashStep)
                 .Then(FetchBranchDataStep)
                 .Then(FinalizeRefresh)
                 .Catch(HandleOperationError)
@@ -94,7 +97,7 @@ namespace Terra.Arbitrator.GUI
                     if (analysisResult.HasConflicts)
                     {
                         var conflictingChanges = _changes.Where(c => analysisResult.ConflictingFiles.Contains(c.FilePath)).ToList();
-                        ConflictResolutionWindow.ShowWindow(this, conflictingChanges);
+                        ConflictResolutionWindow.ShowWindow(this, conflictingChanges, new PullConflictSource());
                     }
                     else
                     {
@@ -278,6 +281,53 @@ namespace Terra.Arbitrator.GUI
             var pathsToReset = selectedFiles.Select(c => c.FilePath).ToList();
             ResetMultipleFiles(pathsToReset);
         }
+        
+        public void StashSelectedFiles()
+        {
+            var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
+            if (!selectedFiles.Any())
+            {
+                InfoMessage = "No files selected to stash.";
+                return;
+            }
+            
+            EditorApplication.LockReloadAssemblies();
+            
+            StartOperation("Checking for existing stash...");
+            GitService.HasStash()
+                .Then(hasStash =>
+                {
+                    FinishOperation();
+                    bool confirmed;
+                    if (hasStash)
+                    {
+                        confirmed = _displayDialog("Overwrite Stash?", 
+                            "A 'Better Git Stash' already exists. Do you want to overwrite it with your currently selected files?", 
+                            "Yes, Overwrite", "Cancel");
+                    }
+                    else
+                    {
+                        confirmed = _displayDialog("Create Stash?", 
+                            $"Are you sure you want to stash the selected {selectedFiles.Count} file(s)?", 
+                            "Yes, Stash", "Cancel");
+                    }
+
+                    if (!confirmed) return;
+                    
+                    StartOperation("Stashing selected files...");
+                    GitService.CreateOrOverwriteStash(selectedFiles)
+                        .Then(successMsg => { InfoMessage = successMsg; })
+                        .Catch(HandleOperationError)
+                        .Finally(Refresh);
+                })
+                .Catch(HandleOperationError)
+                .Finally(() =>
+                {
+                    FinishOperation();
+                    Refresh();
+                    EditorApplication.UnlockReloadAssemblies();
+                });
+        }
 
         public void SetAllSelection(bool selected)
         {
@@ -349,10 +399,16 @@ namespace Terra.Arbitrator.GUI
                 _requestRepaint?.Invoke();
             }
         }
-
-        private IPromise<BranchData> FetchBranchDataStep(int? pullCount)
+        
+        private IPromise<bool> CheckForStashStep(int? pullCount)
         {
             CommitsToPull = pullCount ?? 0;
+            return GitService.HasStash();
+        }
+
+        private IPromise<BranchData> FetchBranchDataStep(bool hasStash)
+        {
+            HasStash = hasStash;
             OperationProgress = 0f;
             OperationProgressMessage = "";
             return GitService.GetBranchData();
@@ -429,6 +485,107 @@ namespace Terra.Arbitrator.GUI
                 });
         }
         
+        public void ResolveConflicts(List<GitChange> resolutions, IConflictSource source)
+        {
+            StartOperation("Resolving conflicts...");
+            EditorApplication.LockReloadAssemblies();
+
+            source.Resolve(resolutions)
+                .Then(successMessage =>
+                {
+                    InfoMessage = successMessage;
+                })
+                .Catch(HandleOperationError)
+                .Finally(() =>
+                {
+                    EditorApplication.UnlockReloadAssemblies();
+                    AssetDatabase.Refresh();
+                    Refresh();
+                });
+        }
+        
+        public void ShowStashedChangesWindow()
+        {
+            var tempChanges = new List<GitChange>();
+            tempChanges.AddRange(_changes);
+            StartOperation("Loading stashed files...");
+            GitService.GetStashedFiles()
+                .Then(stashedFiles =>
+                {
+                    if (stashedFiles.Any())
+                    {
+                        StashedChangesWindow.ShowWindow(this, stashedFiles);
+                    }
+                    else
+                    {
+                        InfoMessage = "No files found in the stash.";
+                    }
+                })
+                .Catch(HandleOperationError)
+                .Finally(() =>
+                {
+                    _changes = tempChanges;
+                    FinishOperation();
+                });
+        }
+
+        public void DiscardStash()
+        {
+            StartOperation("Discarding stash...");
+            GitService.DropStash()
+                .Then(successMessage =>
+                {
+                    InfoMessage = successMessage;
+                })
+                .Catch(HandleOperationError)
+                .Finally(Refresh);
+        }
+        
+        public void DiffStashedFile(GitChange change)
+        {
+            GitService.DiffStashedFile(change)
+                .Catch(ex =>
+                {
+                    var res = _displayDialog("Stash Diff Error", $"Error diffing '{change.FilePath}': {ex.Message}", "Uh-oh", "Ok");
+                    if (!res) return;
+                    Debug.Log($"Error diffing '{change.FilePath}'");
+                    Debug.LogException(ex);
+                });
+        }
+        
+        public void ApplyStash()
+        {
+            StartOperation("Analyzing stash for conflicts...");
+            GitService.AnalyzeStashConflicts()
+                .Then(analysisResult =>
+                {
+                    FinishOperation();
+                    if (analysisResult.HasConflicts)
+                    {
+                        var conflictSource = new StashConflictSource();
+                        GitService.GetStashedFiles().Then(stashedFiles =>
+                        {
+                            var conflictingChanges = stashedFiles
+                                .Where(sf => analysisResult.ConflictingFiles.Contains(sf.FilePath))
+                                .ToList();
+                            ConflictResolutionWindow.ShowWindow(this, conflictingChanges, conflictSource);
+                        });
+                    }
+                    else
+                    {
+                        StartOperation("Applying stash...");
+                        var resolutions = new List<GitChange>();
+                        var source = new StashConflictSource();
+                        source.Resolve(resolutions)
+                            .Then(successMsg => { InfoMessage = successMsg; })
+                            .Catch(HandleOperationError)
+                            .Finally(Refresh);
+                    }
+                })
+                .Catch(HandleOperationError)
+                .Finally(FinishOperation);
+        }
+        
         private bool CancelOperationIfUnsavedScenes()
         {
             var isAnySceneDirty = false;

+ 18 - 1
Assets/Better Git/Arbitrator/Editor/GUI/ArbitratorWindow.cs

@@ -109,6 +109,13 @@ namespace Terra.Arbitrator.GUI
                     _controller.Pull();
                 }
             }
+            var stashSelections = _controller.Changes?.Count(c => c.IsSelectedForCommit) ?? 0;
+            EditorGUI.BeginDisabledGroup(stashSelections == 0);
+            if (GUILayout.Button(new GUIContent("Stash", EditorGUIUtility.IconContent("d_AlphabeticalSorting").image), EditorStyles.toolbarButton, GUILayout.Width(80)))
+            {
+                _controller.StashSelectedFiles();
+            }
+            EditorGUI.EndDisabledGroup();
             
             if (_controller.Changes is { Count: > 0 })
             {
@@ -176,6 +183,15 @@ namespace Terra.Arbitrator.GUI
                 EditorGUILayout.HelpBox(_controller.InfoMessage, MessageType.Info);
             }
         }
+
+        private void DrawStashSection()
+        {
+            if (!_controller.HasStash) return;
+            if (GUILayout.Button("See Stash Changes", GUILayout.Height(30)))
+            {
+                _controller.ShowStashedChangesWindow();
+            }
+        }
         
         private void DrawMainContent()
         {
@@ -202,8 +218,9 @@ namespace Terra.Arbitrator.GUI
             {
                 DrawChangesList();
                 DrawCommitSection();
+                DrawStashSection();
             }
-            else if (_controller.ErrorMessage == null)
+            else if (_controller.ErrorMessage == null && _controller.InfoMessage == null)
             {
                 EditorGUILayout.HelpBox("You are up-to-date! No local changes detected.", MessageType.Info);
             }

+ 13 - 6
Assets/Better Git/Arbitrator/Editor/GUI/ConflictResolutionWindow.cs

@@ -3,7 +3,6 @@
 // A modal editor window that displays pull conflicts and allows the user
 // to select files to reset before proceeding.
 
-using System.IO;
 using UnityEditor;
 using UnityEngine;
 using System.Linq;
@@ -16,22 +15,25 @@ namespace Terra.Arbitrator.GUI
     [Preserve]
     public class ConflictResolutionWindow : EditorWindow
     {
-        private List<GitChange> _conflictingFiles;
         private Vector2 _scrollPosition;
-        private ArbitratorController _controller;
         private GUIStyle _wordWrapStyle;
+        private IConflictSource _conflictSource;
+        private ArbitratorController _controller;
+        private List<GitChange> _conflictingFiles;
 
         /// <summary>
         /// Shows a modal window to display pull conflicts and get user action.
         /// </summary>
         /// <param name="controller">Reference to the arbitrator controller</param>
         /// <param name="conflictingFiles">The list of files that have conflicts.</param>
-        public static void ShowWindow(ArbitratorController controller, IReadOnlyList<GitChange> conflictingFiles)
+        /// <param name="source">The source of the conflict (e.g., pull, stash).</param>
+        public static void ShowWindow(ArbitratorController controller, IReadOnlyList<GitChange> conflictingFiles, IConflictSource source)
         {
             var window = GetWindow<ConflictResolutionWindow>(true, "Pull Conflicts Detected", true);
             window.minSize = new Vector2(600, 400);
             window._controller = controller;
             window._conflictingFiles = new List<GitChange>(conflictingFiles);
+            window._conflictSource = source;
             window.ShowModalUtility();
         }
 
@@ -40,6 +42,8 @@ namespace Terra.Arbitrator.GUI
             _wordWrapStyle ??= new GUIStyle(EditorStyles.label) { wordWrap = true };
             
             EditorGUILayout.HelpBox("A pull would result in conflicts with your local changes. To proceed with a clean pull, you must choose a resolution for the conflicting files listed below.", MessageType.Warning);
+            EditorGUILayout.LabelField($"Mine: {_conflictSource?.MineLabel ?? "Your Changes"}", EditorStyles.boldLabel);
+            EditorGUILayout.LabelField($"Theirs: {_conflictSource?.TheirsLabel ?? "Incoming Changes"}", EditorStyles.boldLabel);
             
             EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
             GUILayout.Label("Filename");
@@ -79,11 +83,14 @@ namespace Terra.Arbitrator.GUI
             GUILayout.FlexibleSpace();
 
             var allResolved = _conflictingFiles != null && _conflictingFiles.All(c => c.Resolution != GitChange.ConflictResolution.None);
-            var pullButtonText = allResolved ? "Pull Safely" : "Pull Anyways (Risky)";
+            var pullButtonText = allResolved ? "Resolve Safely" : "Resolve with Unchosen (Risky)";
 
             if (GUILayout.Button(pullButtonText, GUILayout.Height(30), GUILayout.Width(180)))
             {
-                _controller?.ResolveConflictsAndPull(_conflictingFiles);
+                if (_controller != null && _conflictSource != null)
+                {
+                    _controller.ResolveConflicts(_conflictingFiles, _conflictSource);
+                }
                 Close();
             }
             if (GUILayout.Button("Cancel", GUILayout.Height(30)))

+ 110 - 0
Assets/Better Git/Arbitrator/Editor/GUI/StashedChangesWindow.cs

@@ -0,0 +1,110 @@
+// Copyright (c) 2025 TerraByte Inc.
+//
+// A new, non-dockable editor window for viewing and managing changes
+// stored in the 'Better Git Stash'.
+
+using UnityEditor;
+using UnityEngine;
+using System.IO;
+using UnityEngine.Scripting;
+using Terra.Arbitrator.Services;
+using System.Collections.Generic;
+
+namespace Terra.Arbitrator.GUI
+{
+    [Preserve]
+    public class StashedChangesWindow : EditorWindow
+    {
+        private ArbitratorController _controller;
+        private List<GitChange> _stashedFiles;
+        private Vector2 _scrollPosition;
+
+        public static void ShowWindow(ArbitratorController controller, List<GitChange> stashedFiles)
+        {
+            var window = GetWindow<StashedChangesWindow>(true, "Stashed Changes", true);
+            window.minSize = new Vector2(500, 350);
+            window._controller = controller;
+            window._stashedFiles = stashedFiles;
+            window.ShowUtility();
+        }
+
+        private void OnGUI()
+        {
+            if (_controller == null || _stashedFiles == null)
+            {
+                EditorGUILayout.LabelField("No data available.");
+                return;
+            }
+            
+            DrawHeader();
+            DrawFileList();
+            DrawFooter();
+        }
+
+        private void DrawHeader()
+        {
+            EditorGUILayout.LabelField("Files in 'Better Git Stash'", EditorStyles.boldLabel);
+            EditorGUILayout.BeginHorizontal();
+
+            if (GUILayout.Button("Apply Stash", GUILayout.Height(30)))
+            {
+                _controller.ApplyStash();
+                Close();
+            }
+
+            if (GUILayout.Button("Discard Stash", GUILayout.Height(30)))
+            {
+                if (EditorUtility.DisplayDialog("Discard Stash?", "Are you sure you want to discard the 'Better Git Stash'? This action cannot be undone.", "Yes, Discard", "Cancel"))
+                {
+                    _controller.DiscardStash();
+                    Close();
+                }
+            }
+
+            EditorGUILayout.EndHorizontal();
+            EditorGUILayout.Space();
+        }
+
+        private void DrawFileList()
+        {
+            _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, EditorStyles.helpBox);
+
+            foreach (var change in _stashedFiles)
+            {
+                EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
+                
+                var status = change.Status switch {
+                    LibGit2Sharp.ChangeKind.Added => "[+]",
+                    LibGit2Sharp.ChangeKind.Deleted => "[-]",
+                    LibGit2Sharp.ChangeKind.Modified => "[M]",
+                    _ => "[?]"
+                };
+                
+                EditorGUILayout.LabelField(new GUIContent(status, change.Status.ToString()), GUILayout.Width(25));
+                EditorGUILayout.LabelField(new GUIContent(change.FilePath, change.FilePath));
+
+                if (GUILayout.Button("Diff", GUILayout.Width(60)))
+                {
+                    _controller.DiffStashedFile(change);
+                }
+                
+                EditorGUILayout.EndHorizontal();
+            }
+
+            EditorGUILayout.EndScrollView();
+        }
+
+        private void DrawFooter()
+        {
+            GUILayout.FlexibleSpace();
+            EditorGUILayout.BeginHorizontal();
+            GUILayout.FlexibleSpace();
+            if (GUILayout.Button("Close", GUILayout.Width(100)))
+            {
+                Close();
+            }
+            EditorGUILayout.EndHorizontal();
+            EditorGUILayout.Space();
+        }
+    }
+}

+ 3 - 0
Assets/Better Git/Arbitrator/Editor/GUI/StashedChangesWindow.cs.meta

@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 26797e43a45d489ea0d47557d245c563
+timeCreated: 1751008334

+ 283 - 0
Assets/Better Git/Arbitrator/Editor/Services/GitExecutors.cs

@@ -39,6 +39,7 @@ namespace Terra.Arbitrator.Services
     {
         private static string _projectRoot;
         private static string ProjectRoot => _projectRoot ??= MainThreadDataCache.ProjectRoot;
+        private const string StashMessage = "Better Git Stash";
         
         private static string GetAuthenticatedRemoteUrl()
         {
@@ -63,6 +64,274 @@ namespace Terra.Arbitrator.Services
             return authenticatedUrl;
         }
         
+        public static void HasStashExecutor(Action<bool> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                using var repo = new Repository(ProjectRoot);
+                var stashExists = repo.Stashes.Any(s => s.Message.Contains(StashMessage));
+                resolve(stashExists);
+            }
+            catch (Exception ex)
+            {
+                reject(ex);
+            }
+        }
+
+        public static async void CreateOrOverwriteStashExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changes)
+        {
+            try
+            {
+                var log = new StringBuilder();
+
+                using (var repo = new Repository(ProjectRoot))
+                {
+                    var existingStash = repo.Stashes.FirstOrDefault(s => s.Message.Contains(StashMessage));
+                    if (existingStash != null)
+                    {
+                        repo.Stashes.Remove(repo.Stashes.ToList().IndexOf(existingStash));
+                        log.AppendLine("Dropped existing 'Better Git Stash'.");
+                    }
+                }
+
+                var untrackedFiles = new List<string>();
+                
+                using (var repo = new Repository(ProjectRoot))
+                {
+                    foreach (var change in changes)
+                    {
+                        var statusEntry = repo.RetrieveStatus(change.FilePath);
+                        if (statusEntry == FileStatus.NewInWorkdir)
+                        {
+                            untrackedFiles.Add(change.FilePath);
+                        }
+                    }
+                }
+
+                if (untrackedFiles.Any())
+                {
+                    foreach (var file in untrackedFiles)
+                    {
+                        await GitCommand.RunGitAsync(log, new[] { "add", file });
+                    }
+                    log.AppendLine($"Staged {untrackedFiles.Count} untracked files.");
+                }
+
+                var allFiles = changes.Select(c => c.FilePath).ToList();
+                if (allFiles.Any())
+                {
+                    var stashArgs = new List<string> { "stash", "push", "-m", StashMessage, "--" };
+                    stashArgs.AddRange(allFiles);
+                    
+                    await GitCommand.RunGitAsync(log, stashArgs.ToArray());
+                }
+                else
+                {
+                    throw new Exception("No files to stash.");
+                }
+                
+                resolve("Successfully created new 'Better Git Stash'.");
+            }
+            catch (Exception ex)
+            {
+                Debug.LogException(ex);
+                reject(ex);
+            }
+        }
+        
+        public static void DropStashExecutor(Action<string> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                using var repo = new Repository(ProjectRoot);
+                var stash = repo.Stashes.FirstOrDefault(s => s.Message.Contains(StashMessage));
+                if (stash != null)
+                {
+                    repo.Stashes.Remove(repo.Stashes.ToList().IndexOf(stash));
+                    resolve("'Better Git Stash' has been discarded.");
+                }
+                else
+                {
+                    resolve("No 'Better Git Stash' found to discard.");
+                }
+            }
+            catch (Exception ex)
+            {
+                reject(ex);
+            }
+        }
+        
+        public static void GetStashedFilesExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                using var repo = new Repository(ProjectRoot);
+                var stash = repo.Stashes.FirstOrDefault(s => s.Message.Contains(StashMessage));
+                if (stash == null)
+                {
+                    resolve(new List<GitChange>());
+                    return;
+                }
+        
+                var changes = new List<GitChange>();
+        
+                var stashChanges = repo.Diff.Compare<TreeChanges>(stash.Base.Tree, stash.WorkTree.Tree);
+                foreach (var change in stashChanges)
+                {
+                    changes.Add(new GitChange(change.Path, change.OldPath, change.Status));
+                }
+        
+                var indexChanges = repo.Diff.Compare<TreeChanges>(stash.Base.Tree, stash.Index.Tree);
+                foreach (var change in indexChanges)
+                {
+                    if (changes.All(c => c.FilePath != change.Path))
+                    {
+                        changes.Add(new GitChange(change.Path, change.OldPath, change.Status));
+                    }
+                }
+        
+                resolve(changes);
+            }
+            catch(Exception ex)
+            {
+                reject(ex);
+            }
+        }
+
+        public static async void DiffStashedFileExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
+        {
+            string fileAPath = null;
+            string fileBPath = null;
+            
+            try
+            {
+                using var repo = new Repository(ProjectRoot);
+                var stash = repo.Stashes.FirstOrDefault(s => s.Message.Contains(StashMessage));
+                if (stash == null) throw new Exception("'Better Git Stash' not found.");
+
+                var stashedTree = stash.WorkTree.Tree; 
+                var baseTree = stash.Base.Tree; 
+
+                switch (change.Status)
+                {
+                    case ChangeKind.Added:
+                        fileAPath = CreateTempFileWithContent("", "empty");
+                        fileBPath = CreateTempFileFromBlob(stashedTree[change.FilePath]?.Target as Blob, change.FilePath);
+                        break;
+                    
+                    case ChangeKind.Deleted:
+                        fileAPath = CreateTempFileFromBlob(baseTree[change.FilePath]?.Target as Blob, change.FilePath);
+                        fileBPath = CreateTempFileWithContent("", change.FilePath);
+                        break;
+
+                    case ChangeKind.Renamed:
+                        fileAPath = CreateTempFileFromBlob(baseTree[change.OldFilePath]?.Target as Blob, change.OldFilePath);
+                        fileBPath = Path.Combine(ProjectRoot, change.FilePath);
+                        break;
+
+                    default:
+                        fileAPath = CreateTempFileFromBlob(stashedTree[change.FilePath]?.Target as Blob, change.FilePath);
+                        fileBPath = Path.Combine(ProjectRoot, change.FilePath);
+                        break;
+                }
+
+                if (!File.Exists(fileBPath))
+                {
+                    fileBPath = CreateTempFileWithContent("", Path.GetFileName(fileBPath));
+                }
+
+                await GitCommand.RunVsCodeAsync(new StringBuilder(), new[] { "--diff", fileAPath, fileBPath });
+                resolve("Launched external diff tool.");
+            }
+            catch (Exception ex)
+            {
+                reject(ex);
+            }
+            finally
+            {
+                if (fileAPath != null && fileAPath.Contains(Path.GetTempPath())) File.Delete(fileAPath);
+                if (fileBPath != null && fileBPath.Contains(Path.GetTempPath())) File.Delete(fileBPath);
+            }
+        }
+        
+        public static void AnalyzeStashConflictsExecutor(Action<PullAnalysisResult> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                using var repo = new Repository(ProjectRoot);
+                var stash = repo.Stashes.FirstOrDefault(s => s.Message.Contains(StashMessage));
+                if (stash == null)
+                {
+                    resolve(new PullAnalysisResult(new List<string>()));
+                    return;
+                }
+
+                var stashedChanges = new HashSet<string>(repo.Diff.Compare<TreeChanges>(stash.Base.Tree, stash.Index.Tree).Select(c => c.Path));
+
+                var localChanges = new HashSet<string>(repo.RetrieveStatus().Where(s => s.State != FileStatus.Ignored).Select(s => s.FilePath));
+
+                var conflictingFiles = stashedChanges.Intersect(localChanges).ToList();
+                
+                resolve(new PullAnalysisResult(conflictingFiles));
+            }
+            catch (Exception ex)
+            {
+                reject(ex);
+            }
+        }
+
+        public static async void ApplyStashAndOverwriteExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> resolutions)
+        {
+            var tempFiles = new Dictionary<string, string>();
+            var log = new StringBuilder();
+            try
+            {
+                foreach (var resolution in resolutions.Where(r => r.Resolution == GitChange.ConflictResolution.Mine))
+                {
+                    var fullPath = Path.Combine(ProjectRoot, resolution.FilePath);
+                    if (File.Exists(fullPath))
+                    {
+                        var tempPath = Path.GetTempFileName();
+                        File.Copy(fullPath, tempPath, true);
+                        tempFiles[resolution.FilePath] = tempPath;
+                    }
+                }
+
+                using (var repo = new Repository(ProjectRoot))
+                {
+                    var filesToReset = resolutions.Where(r => r.Resolution != GitChange.ConflictResolution.None).Select(r => r.FilePath).ToArray();
+                    if (filesToReset.Any())
+                    {
+                        repo.CheckoutPaths(repo.Head.Tip.Sha, filesToReset, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
+                    }
+                }
+                
+                await GitCommand.RunGitAsync(log, new[] { "stash", "apply" });
+
+                foreach (var entry in tempFiles)
+                {
+                    var finalPath = Path.Combine(ProjectRoot, entry.Key);
+                    File.Copy(entry.Value, finalPath, true);
+                    await GitCommand.RunGitAsync(log, new[] { "add", entry.Key });
+                }
+
+                await GitCommand.RunGitAsync(log, new[] { "stash", "drop" });
+
+                resolve("Stash applied successfully and has been dropped.");
+            }
+            catch (Exception ex)
+            {
+                reject(new Exception($"Failed to apply stash. You may need to resolve conflicts manually. Details: {ex.Message}"));
+            }
+            finally
+            {
+                foreach (var tempFile in tempFiles.Values.Where(File.Exists))
+                {
+                    File.Delete(tempFile);
+                }
+            }
+        }
+        
         public static void GetBranchDataExecutor(Action<BranchData> resolve, Action<Exception> reject)
         {
             try
@@ -630,5 +899,19 @@ namespace Terra.Arbitrator.Services
                 reject(ex);
             }
         }
+        
+        private static string CreateTempFileFromBlob(Blob blob, string fallbackFileName)
+        {
+            var content = blob?.GetContentText() ?? "";
+            return CreateTempFileWithContent(content, fallbackFileName);
+        }
+        
+        private static string CreateTempFileWithContent(string content, string originalFileName)
+        {
+            var tempFileName = $"{Path.GetFileName(originalFileName)}-{Path.GetRandomFileName()}";
+            var tempPath = Path.Combine(Path.GetTempPath(), tempFileName);
+            File.WriteAllText(tempPath, content);
+            return tempPath;
+        }
     }
 }

+ 59 - 1
Assets/Better Git/Arbitrator/Editor/Services/GitService.cs

@@ -54,7 +54,6 @@ namespace Terra.Arbitrator.Services
         /// <summary>
         /// Gets the number of incoming commits from the tracked remote branch.
         /// </summary>
-        /// <returns>A promise that resolves with the number of commits, or null if not tracked.</returns>
         public static IPromise<int?> GetUpstreamAheadBy(Action<float, string> onProgress = null)
         {
             return new Promise<int?>((resolve, reject) => GitExecutors.GetUpstreamAheadByExecutor(resolve, reject, onProgress));
@@ -133,5 +132,64 @@ namespace Terra.Arbitrator.Services
         {
             return new Promise<bool>(GitExecutors.UnstageAllFilesIfSafeExecutor);
         }
+        
+        /// <summary>
+        /// Checks whether there is an existing stash with the specified message in the repository.
+        /// </summary>
+        public static IPromise<bool> HasStash()
+        {
+            return new Promise<bool>(GitExecutors.HasStashExecutor);
+        }
+
+        /// <summary>
+        /// Creates or overwrites a stash with the given message in the repository.
+        /// If a stash with the same message already exists, it is dropped before the new one is created.
+        /// </summary>
+        public static IPromise<string> CreateOrOverwriteStash(List<GitChange> changes)
+        {
+            return new Promise<string>((res, rej) => GitExecutors.CreateOrOverwriteStashExecutor(res, rej, changes));
+        }
+
+        /// <summary>
+        /// Drops a stash with the specified message in the repository.
+        /// </summary>
+        public static IPromise<string> DropStash()
+        {
+            return new Promise<string>(GitExecutors.DropStashExecutor);
+        }
+
+        /// <summary>
+        /// Gets the list of files in the stashes with the specified message in the repository.
+        /// </summary>
+        public static IPromise<List<GitChange>> GetStashedFiles()
+        {
+            return new Promise<List<GitChange>>(GitExecutors.GetStashedFilesExecutor);
+        }
+
+        /// <summary>
+        /// Launches an external diff tool to compare the stashed and local versions of a file.
+        /// </summary>
+        public static IPromise<string> DiffStashedFile(GitChange change)
+        {
+            return new Promise<string>((res, rej) => GitExecutors.DiffStashedFileExecutor(res, rej, change));
+        }
+        
+        /// <summary>
+        /// Analyzes if a stash application results in conflicts.
+        /// </summary>
+        public static IPromise<PullAnalysisResult> AnalyzeStashConflicts()
+        {
+            return new Promise<PullAnalysisResult>(GitExecutors.AnalyzeStashConflictsExecutor);
+        }
+
+        /// <summary>
+        /// Applies the stashed changes and overwrites the existing ones in the working directory.
+        /// Handles conflicts based on the provided resolutions.
+        /// </summary>
+        public static IPromise<string> ApplyStashAndOverwrite(List<GitChange> resolutions)
+        {
+            return new Promise<string>((res, rej) =>
+                GitExecutors.ApplyStashAndOverwriteExecutor(res, rej, resolutions));
+        }
     }
 }

+ 27 - 0
Assets/Better Git/Arbitrator/Editor/Services/IConflictSource.cs

@@ -0,0 +1,27 @@
+// Copyright (c) 2025 TerraByte Inc.
+//
+// Defines a generic interface for any operation that can result in a merge
+// conflict, such as a pull or a stash apply. This allows the conflict
+// resolution UI to be agnostic to the source of the conflict.
+
+using Terra.Arbitrator.Promises;
+using System.Collections.Generic;
+
+namespace Terra.Arbitrator.Services
+{
+    /// <summary>
+    /// Provides a standardized way to handle different types of merge conflicts.
+    /// </summary>
+    public interface IConflictSource
+    {
+        public string MineLabel { get; }
+        public string TheirsLabel { get; }
+
+        /// <summary>
+        /// Executes the logic to resolve the conflicts based on the user's choices.
+        /// </summary>
+        /// <param name="resolutions">The list of files and their chosen resolution.</param>
+        /// <returns>A promise that resolves with a success message or rejects with an error.</returns>
+        public IPromise<string> Resolve(List<GitChange> resolutions);
+    }
+}

+ 3 - 0
Assets/Better Git/Arbitrator/Editor/Services/IConflictSource.cs.meta

@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5b553ac01fbc48b484cc9a8de4e511d4
+timeCreated: 1751007052

+ 24 - 0
Assets/Better Git/Arbitrator/Editor/Services/PullConflictSource.cs

@@ -0,0 +1,24 @@
+// Copyright (c) 2025 TerraByte Inc.
+//
+// An implementation of IConflictSource specifically for handling conflicts
+// that arise from a 'git pull' operation.
+
+using Terra.Arbitrator.Promises;
+using System.Collections.Generic;
+
+namespace Terra.Arbitrator.Services
+{
+    /// <summary>
+    /// Handles the resolution of conflicts from a git pull.
+    /// </summary>
+    public class PullConflictSource : IConflictSource
+    {
+        public string MineLabel => "Your Local Changes";
+        public string TheirsLabel => "Incoming Remote Changes";
+
+        public IPromise<string> Resolve(List<GitChange> resolutions)
+        {
+            return GitService.PullAndOverwrite(resolutions);
+        }
+    }
+}

+ 3 - 0
Assets/Better Git/Arbitrator/Editor/Services/PullConflictSource.cs.meta

@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: c99ed4a30b5e48c6b1f579ac363cc765
+timeCreated: 1751007121

+ 24 - 0
Assets/Better Git/Arbitrator/Editor/Services/StashConflictSource.cs

@@ -0,0 +1,24 @@
+// Copyright (c) 2025 TerraByte Inc.
+//
+// An implementation of IConflictSource specifically for handling conflicts
+// that arise from a 'git stash apply' operation.
+
+using Terra.Arbitrator.Promises;
+using System.Collections.Generic;
+
+namespace Terra.Arbitrator.Services
+{
+    /// <summary>
+    /// Handles the resolution of conflicts from a git stash apply.
+    /// </summary>
+    public class StashConflictSource : IConflictSource
+    {
+        public string MineLabel => "Your Current Local Changes";
+        public string TheirsLabel => "Changes in 'Better Git Stash'";
+
+        public IPromise<string> Resolve(List<GitChange> resolutions)
+        {
+            return GitService.ApplyStashAndOverwrite(resolutions);
+        }
+    }
+}

+ 3 - 0
Assets/Better Git/Arbitrator/Editor/Services/StashConflictSource.cs.meta

@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: e84be85b774d40a382ec0195dd301568
+timeCreated: 1751008693