Browse Source

Suijth :) ->
1. Small quality of life improvements

sujith 1 month ago
parent
commit
034fd4c60f

+ 164 - 46
Assets/Arbitrator/Editor/GUI/ArbitratorController.cs

@@ -11,9 +11,14 @@ using Terra.Arbitrator.Settings;
 using Terra.Arbitrator.Services;
 using Terra.Arbitrator.Promises;
 using System.Collections.Generic;
+using UnityEditor.SceneManagement;
 
 namespace Terra.Arbitrator.GUI
 {
+    public enum UserAction { Proceed, SaveAndProceed, Cancel }
+
+    public enum SortColumn { Commit, Status, FilePath }
+    
     public class ArbitratorController
     {
         private List<GitChange> _changes = new();
@@ -22,19 +27,25 @@ namespace Terra.Arbitrator.GUI
         public string ErrorMessage { get; private set; }
         public bool IsLoading { get; private set; }
         public string LoadingMessage { get; private set; } = "";
+        public bool IsInConflictState { get; private set; }
+        public int CommitsToPull { get; private set; }
+        public SortColumn CurrentSortColumn { get; private set; } = SortColumn.FilePath;
 
         private readonly Action _requestRepaint;
-        private readonly Func<string, string, bool> _displayDialog;
+        private readonly Func<string, string, string, string, bool> _displayDialog;
+        private readonly Func<UserAction> _promptForUnsavedChanges;
 
         /// <summary>
         /// Initializes the controller.
         /// </summary>
         /// <param name="requestRepaint">A callback to the window's Repaint() method.</param>
         /// <param name="displayDialog">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
-        public ArbitratorController(Action requestRepaint, Func<string, string, bool> displayDialog)
+        /// <param name="promptForUnsavedChanges">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
+        public ArbitratorController(Action requestRepaint, Func<string, string, string, string, bool> displayDialog, Func<UserAction> promptForUnsavedChanges)
         {
             _requestRepaint = requestRepaint;
             _displayDialog = displayDialog;
+            _promptForUnsavedChanges = promptForUnsavedChanges;
         }
         
         public void OnEnable()
@@ -54,10 +65,12 @@ namespace Terra.Arbitrator.GUI
         public void Refresh()
         {
             StartOperation("Refreshing status...");
+            CommitsToPull = 0;
             
             UnstageStep()
                 .Then(CompareStep)
-                .Then(changes => _changes = changes)
+                .Then(FetchUpstreamStep)
+                .Then(FinalizeRefresh)
                 .Catch(HandleOperationError)
                 .Finally(FinishOperation);
         }
@@ -65,42 +78,71 @@ namespace Terra.Arbitrator.GUI
         public void Pull()
         {
             if (IsLoading) return;
+            if (CancelOperationIfUnsavedScenes()) return;
+            if (CommitsToPull > 0)
+            {
+                if (!_displayDialog("Confirm Pull", $"There are {CommitsToPull} incoming changes. Are you sure you want to pull?", "Yes, Pull", "Cancel"))
+                {
+                    return;
+                }
+            }
+
             StartOperation("Analyzing for conflicts...");
 
             GitService.AnalyzePullConflicts()
                 .Then(analysisResult =>
                 {
+                    FinishOperation(); // Stop loading before showing dialog
                     if (analysisResult.HasConflicts)
                     {
-                        FinishOperation();
-                        ConflictResolutionWindow.ShowWindow(analysisResult.ConflictingFiles, filesToActOn =>
-                        {
-                            if (filesToActOn == null) return; 
-                            if (filesToActOn.Any()) ResetMultipleFiles(filesToActOn);
-                            else ForcePull(); 
-                        });
+                        ConflictResolutionWindow.ShowWindow(this, analysisResult.ConflictingFiles);
                     }
                     else
                     {
-                        StartOperation("No conflicts found. Pulling changes...");
-                        GitService.PerformSafePull()
-                            .Then(successMessage =>
-                            {
-                                InfoMessage = successMessage;
-                                // Refresh will handle its own start/finish states
-                                Refresh();
-                            })
-                            .Catch(ex => {
-                                // If the pull fails, we need to handle the error and finish
-                                HandleOperationError(ex);
-                                FinishOperation();
-                            });
+                        PerformSafePullWithLock();
                     }
                 })
                 .Catch(ex => {
                     HandleOperationError(ex);
                     FinishOperation();
-                });
+                })
+                .Finally(FinishOperation);
+        }
+        
+        public void PerformSafePullWithLock()
+        {
+            StartOperation("Pulling changes...");
+            EditorApplication.LockReloadAssemblies();
+            GitService.PerformSafePull()
+                .Then(successMessage =>
+                {
+                    InfoMessage = successMessage;
+                    Refresh();
+                })
+                .Catch(ex => {
+                    HandleOperationError(ex);
+                    FinishOperation();
+                })
+                .Finally(EditorApplication.UnlockReloadAssemblies);
+        }
+        
+        public void ResetSingleConflictingFile(string filePath, Action onResetComplete)
+        {
+            var change = GitService.GetChangeForFile(filePath);
+            if (change == null)
+            {
+                ErrorMessage = $"Could not find file '{filePath}' to reset.";
+                onResetComplete?.Invoke();
+                return;
+            }
+            
+            GitService.ResetFileChanges(change)
+                .Then(successMessage =>
+                {
+                    InfoMessage = successMessage;
+                })
+                .Catch(HandleOperationError)
+                .Finally(onResetComplete);
         }
 
         public void CommitAndPush(string commitMessage)
@@ -122,11 +164,47 @@ namespace Terra.Arbitrator.GUI
                     FinishOperation();
                 });
         }
+        
+        public void SetSortColumn(SortColumn newColumn)
+        {
+            // If it's already the active column, do nothing.
+            if (CurrentSortColumn == newColumn) return;
+
+            CurrentSortColumn = newColumn;
+            ApplyGrouping();
+            _requestRepaint?.Invoke();
+        }
+
+        private void ApplyGrouping()
+        {
+            if (_changes == null || !_changes.Any()) return;
+
+            _changes = CurrentSortColumn switch
+            {
+                SortColumn.Commit => _changes.OrderByDescending(c => c.IsSelectedForCommit).ThenBy(c => c.FilePath).ToList(),
+                SortColumn.Status => _changes.OrderBy(c => GetStatusSortPriority(c.Status)).ThenBy(c => c.FilePath).ToList(),
+                SortColumn.FilePath => _changes.OrderBy(c => c.FilePath).ToList(),
+                _ => _changes
+            };
+        }
+        
+        private int GetStatusSortPriority(LibGit2Sharp.ChangeKind status)
+        {
+            return status switch
+            {
+                LibGit2Sharp.ChangeKind.Conflicted => -1, // Always show conflicts on top
+                LibGit2Sharp.ChangeKind.Modified => 0,
+                LibGit2Sharp.ChangeKind.Added => 1,
+                LibGit2Sharp.ChangeKind.Deleted => 2,
+                LibGit2Sharp.ChangeKind.Renamed => 3,
+                _ => 99
+            };
+        }
 
         public void ResetFile(GitChange change)
         {
             if (IsLoading) return;
-            var userConfirmed = _displayDialog("Confirm Reset", $"Are you sure you want to revert all local changes to '{change.FilePath}'? This action cannot be undone.");
+            var userConfirmed = _displayDialog("Confirm Reset", $"Are you sure you want to revert all local changes to '{change.FilePath}'? This action cannot be undone.", "Yes, Revert", "Cancel");
             if (!userConfirmed) return;
             
             StartOperation($"Resetting {change.FilePath}...");
@@ -179,35 +257,25 @@ namespace Terra.Arbitrator.GUI
                 .Catch(HandleOperationError)
                 .Finally(Refresh);
         }
-
-        public void ResetAll()
+        
+        public void ResetSelected()
         {
-            var userConfirmed = _displayDialog(
-                "Confirm Reset All",
-                "Are you sure you want to discard ALL local changes (modified, added, and untracked files)?\n\nThis action cannot be undone.");
-
-            if (!userConfirmed) return;
-
-            StartOperation("Discarding all local changes...");
+            var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
+            if (!selectedFiles.Any()) return;
 
-            GitService.ResetAllChanges()
-                .Then(successMessage =>
-                {
-                    InfoMessage = successMessage;
-                })
-                .Catch(HandleOperationError)
-                .Finally(Refresh);
+            var fileList = string.Join("\n - ", selectedFiles.Select(f => f.FilePath));
+            if (!_displayDialog("Confirm Reset Selected", $"Are you sure you want to revert changes for the following {selectedFiles.Count} file(s)?\n\n - {fileList}", "Yes, Revert Selected", "Cancel")) return;
+            
+            var pathsToReset = selectedFiles.Select(c => c.FilePath).ToList();
+            ResetMultipleFiles(pathsToReset);
         }
 
         public void SetAllSelection(bool selected)
         {
             if (_changes == null) return;
-            foreach (var change in _changes)
+            foreach (var change in _changes.Where(change => change.Status != LibGit2Sharp.ChangeKind.Conflicted))
             {
-                if (change.Status != LibGit2Sharp.ChangeKind.Conflicted)
-                {
-                   change.IsSelectedForCommit = selected;
-                }
+                change.IsSelectedForCommit = selected;
             }
         }
 
@@ -227,6 +295,25 @@ namespace Terra.Arbitrator.GUI
             return GitService.CompareLocalToRemote();
         }
         
+        private IPromise<int?> FetchUpstreamStep(List<GitChange> changes)
+        {
+            _changes = changes;
+            IsInConflictState = _changes.Any(c => c.Status == LibGit2Sharp.ChangeKind.Conflicted);
+
+            return IsInConflictState ?
+                // If in conflict, we can't pull, so we don't need to check. Return a resolved promise.
+                new Promise<int?>((resolve, _) => resolve(0)) : GitService.GetUpstreamAheadBy();
+        }
+
+        private void FinalizeRefresh(int? pullCount)
+        {
+            CommitsToPull = pullCount ?? 0;
+            if (string.IsNullOrEmpty(InfoMessage) && (_changes == null || _changes.Count == 0))
+            {
+                InfoMessage = "You are up-to-date! No local changes detected.";
+            }
+        }
+        
         // --- Shared Helper Methods ---
 
         private void StartOperation(string loadingMessage)
@@ -281,5 +368,36 @@ namespace Terra.Arbitrator.GUI
                     Refresh();
                 });
         }
+        
+        private bool CancelOperationIfUnsavedScenes()
+        {
+            var isAnySceneDirty = false;
+            for (var i = 0; i < EditorSceneManager.sceneCount; i++)
+            {
+                var scene = EditorSceneManager.GetSceneAt(i);
+                if (!scene.isDirty) continue;
+                isAnySceneDirty = true;
+                break;
+            }
+
+            if (!isAnySceneDirty)
+            {
+                return false;
+            }
+            
+            var userChoice = _promptForUnsavedChanges();
+
+            switch (userChoice)
+            {
+                case UserAction.SaveAndProceed:
+                    EditorSceneManager.SaveOpenScenes();
+                    return false;
+                case UserAction.Proceed:
+                    return false;
+                case UserAction.Cancel:
+                default:
+                    return true;
+            }
+        }
     }
 }

+ 71 - 12
Assets/Arbitrator/Editor/GUI/ArbitratorWindow.cs

@@ -29,7 +29,8 @@ namespace Terra.Arbitrator.GUI
         {
             _controller = new ArbitratorController(
                 requestRepaint: Repaint,
-                displayDialog: (heading, message) => EditorUtility.DisplayDialog(heading, message, "Yes", "No")
+                displayDialog: EditorUtility.DisplayDialog,
+                promptForUnsavedChanges: PromptForUnsavedChanges
             );
             
             _controller.OnEnable();
@@ -52,6 +53,14 @@ namespace Terra.Arbitrator.GUI
             
             _stylesInitialized = true;
         }
+        
+        public void TriggerAutoRefresh()
+        {
+            if (_controller is { IsLoading: false })
+            {
+                _controller.Refresh();
+            }
+        }
 
         private void OnGUI()
         {
@@ -71,10 +80,18 @@ namespace Terra.Arbitrator.GUI
             {
                 _controller.Refresh();
             }
-
-            if (GUILayout.Button(new GUIContent("Pull", EditorGUIUtility.IconContent("CollabPull").image), EditorStyles.toolbarButton, GUILayout.Width(80)))
+            
+            if (!_controller.IsInConflictState)
             {
-                _controller.Pull();
+                var pullLabel = "Pull";
+                if (_controller.CommitsToPull > 0)
+                {
+                    pullLabel = $"Pull ({_controller.CommitsToPull})";
+                }
+                if (GUILayout.Button(new GUIContent(pullLabel), EditorStyles.toolbarButton, GUILayout.Width(80)))
+                {
+                    _controller.Pull();
+                }
             }
             
             if (_controller.Changes is { Count: > 0 })
@@ -92,15 +109,19 @@ namespace Terra.Arbitrator.GUI
 
                 var noChanges = !_controller.Changes.Any();
                 EditorGUI.BeginDisabledGroup(noChanges);
+                
+                var selectedCount = _controller.Changes.Count(c => c.IsSelectedForCommit);
+                EditorGUI.BeginDisabledGroup(selectedCount == 0);
+                
                 var originalColor = UnityEngine.GUI.backgroundColor;
                 UnityEngine.GUI.backgroundColor = new Color(1f, 0.5f, 0.5f, 0.8f);
-
-                if (GUILayout.Button(new GUIContent("Reset All", EditorGUIUtility.IconContent("d_TreeEditor.Trash").image), EditorStyles.toolbarButton, GUILayout.Width(100)))
+                if (GUILayout.Button(new GUIContent($"Reset Selected ({selectedCount})", EditorGUIUtility.IconContent("d_TreeEditor.Trash").image), EditorStyles.toolbarButton, GUILayout.Width(130)))
                 {
-                    EditorApplication.delayCall += _controller.ResetAll;
+                    EditorApplication.delayCall += _controller.ResetSelected;
                 }
-            
                 UnityEngine.GUI.backgroundColor = originalColor;
+                
+                EditorGUI.EndDisabledGroup();
                 EditorGUI.EndDisabledGroup();
             }
 
@@ -153,10 +174,11 @@ namespace Terra.Arbitrator.GUI
         private void DrawChangesList()
         {
             EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
-            EditorGUILayout.LabelField("Commit", GUILayout.Width(45));
-            EditorGUILayout.LabelField("Status", GUILayout.Width(50));
-            EditorGUILayout.LabelField("File Path");
-            EditorGUILayout.LabelField("Actions", GUILayout.Width(130));
+            DrawHeaderButton("Commit", SortColumn.Commit, 45);
+            GUILayout.Space(10);
+            DrawHeaderButton("Status", SortColumn.Status, 50);
+            DrawHeaderButton("File Path", SortColumn.FilePath, -1);
+            EditorGUILayout.LabelField("Actions", GUILayout.Width(100));
             EditorGUILayout.EndHorizontal();
 
             _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
@@ -166,6 +188,7 @@ namespace Terra.Arbitrator.GUI
                 var rowStyle = i % 2 == 0 ? _evenRowStyle : GUIStyle.none;
                 
                 EditorGUILayout.BeginHorizontal(rowStyle);
+                GUILayout.Space(15);
 
                 if (change.Status == LibGit2Sharp.ChangeKind.Conflicted)
                 {
@@ -233,6 +256,24 @@ namespace Terra.Arbitrator.GUI
             EditorGUILayout.EndScrollView();
         }
         
+        private void DrawHeaderButton(string text, SortColumn column, float width)
+        {
+            var label = text;
+            if (_controller.CurrentSortColumn == column)
+            {
+                label = $"[*] {text}";
+            }
+            var buttonStyle = new GUIStyle(EditorStyles.label) { alignment = TextAnchor.MiddleLeft };
+            if (width > 0)
+            {
+                if (GUILayout.Button(label, buttonStyle, GUILayout.Width(width))) { _controller.SetSortColumn(column); }
+            }
+            else
+            {
+                if (GUILayout.Button(label, buttonStyle)) { _controller.SetSortColumn(column); }
+            }
+        }
+        
         private void DrawCommitSection()
         {
             EditorGUILayout.Space(10);
@@ -260,5 +301,23 @@ namespace Terra.Arbitrator.GUI
                 EditorGUILayout.HelpBox("Please enter a commit message and select at least one file.", MessageType.Warning);
             }
         }
+        
+        private static UserAction PromptForUnsavedChanges()
+        {
+            var result = EditorUtility.DisplayDialogComplex(
+                "Unsaved Scene Changes",
+                "You have unsaved changes in the current scene. Would you like to save them before proceeding?",
+                "Save and Continue",
+                "Cancel",
+                "Continue without Saving");
+
+            return result switch
+            {
+                0 => UserAction.SaveAndProceed,
+                1 => UserAction.Cancel,        
+                2 => UserAction.Proceed,       
+                _ => UserAction.Cancel
+            };
+        }
     }
 }

+ 41 - 65
Assets/Arbitrator/Editor/GUI/ConflictResolutionWindow.cs

@@ -3,11 +3,8 @@
 // A modal editor window that displays pull conflicts and allows the user
 // to select files to reset before proceeding.
 
-using System;
-using System.Linq;
 using UnityEditor;
 using UnityEngine;
-using Terra.Arbitrator.Services;
 using System.Collections.Generic;
 
 namespace Terra.Arbitrator.GUI
@@ -15,107 +12,86 @@ namespace Terra.Arbitrator.GUI
     public class ConflictResolutionWindow : EditorWindow
     {
         private List<string> _conflictingFiles;
-        private List<bool> _filesToReset;
+        private string _fileBeingReset;
         private Vector2 _scrollPosition;
-        private Action<List<string>> _onCloseCallback;
-        private bool _callbackInvoked;
+        private ArbitratorController _controller;
 
         /// <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>
-        /// <param name="onCloseCallback">A callback that provides the list of files the user selected to reset. If the user cancels, the list will be null.</param>
-        public static void ShowWindow(List<string> conflictingFiles, Action<List<string>> onCloseCallback)
+        public static void ShowWindow(ArbitratorController controller, List<string> conflictingFiles)
         {
-            var window = GetWindow<ConflictResolutionWindow>(true, "Conflicts Detected", true);
+            var window = GetWindow<ConflictResolutionWindow>(true, "Pull Conflicts Detected", true);
             window.minSize = new Vector2(600, 400);
-            window._conflictingFiles = conflictingFiles;
-            window._filesToReset = new List<bool>(new bool[conflictingFiles.Count]); // Initialize all to false
-            window._onCloseCallback = onCloseCallback;
-            window._callbackInvoked = false;
+            window._controller = controller;
+            window._conflictingFiles = new List<string>(conflictingFiles);
             window.ShowModalUtility();
         }
 
         private void OnGUI()
         {
             EditorGUILayout.HelpBox("A pull would result in conflicts with your local changes. To proceed with a clean pull, you must reset your local changes for the conflicting files listed below.", MessageType.Warning);
-            
             EditorGUILayout.LabelField("Conflicting Files:", EditorStyles.boldLabel);
 
             _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, EditorStyles.helpBox);
-            for (var i = 0; i < _conflictingFiles.Count; i++)
+            foreach (var file in new List<string>(_conflictingFiles))
             {
-                EditorGUILayout.BeginHorizontal();
-                _filesToReset[i] = EditorGUILayout.Toggle(_filesToReset[i], GUILayout.Width(20));
-                EditorGUILayout.LabelField(_conflictingFiles[i]);
-                if (GUILayout.Button("Local Diff", GUILayout.Width(100)))
-                {
-                    // Find the GitChange object to pass to the diff service.
-                    var change = GitService.GetChangeForFile(_conflictingFiles[i]);
-                    if (change != null)
-                    {
-                        GitService.LaunchExternalDiff(change);
-                    }
-                    else
-                    {
-                        Debug.LogWarning($"Could not get status for {_conflictingFiles[i]} to launch diff.");
-                    }
-                }
-                EditorGUILayout.EndHorizontal();
+                DrawFileRow(file);
             }
             EditorGUILayout.EndScrollView();
 
             EditorGUILayout.Space();
-
             DrawActionButtons();
         }
-
-        private void DrawActionButtons()
+        
+        private void DrawFileRow(string filePath)
         {
-            var selectedCount = _filesToReset.Count(selected => selected);
-            var canReset = selectedCount > 0;
-
             EditorGUILayout.BeginHorizontal();
-            GUILayout.FlexibleSpace();
+            EditorGUILayout.LabelField(new GUIContent(filePath, filePath));
+
+            var isThisFileLoading = _fileBeingReset == filePath;
 
-            EditorGUI.BeginDisabledGroup(!canReset);
-            if (GUILayout.Button($"Reset {selectedCount} Selected File(s)", GUILayout.Height(30), GUILayout.Width(200)))
+            EditorGUI.BeginDisabledGroup(isThisFileLoading || !string.IsNullOrEmpty(_fileBeingReset));
+            if (GUILayout.Button(isThisFileLoading ? "Resetting..." : "Reset", GUILayout.Width(100)))
             {
-                var filesToResetPaths = new List<string>();
-                for (var i = 0; i < _conflictingFiles.Count; i++)
-                {
-                    if (_filesToReset[i])
-                    {
-                        filesToResetPaths.Add(_conflictingFiles[i]);
-                    }
-                }
-                _callbackInvoked = true;
-                _onCloseCallback?.Invoke(filesToResetPaths);
-                Close();
+                HandleResetFile(filePath);
             }
             EditorGUI.EndDisabledGroup();
+            
+            EditorGUILayout.EndHorizontal();
+        }
+        
+        private void HandleResetFile(string filePath)
+        {
+            _fileBeingReset = filePath;
+            Repaint();
+            
+            _controller.ResetSingleConflictingFile(filePath, () =>
+            {
+                _conflictingFiles.Remove(filePath);
+                _fileBeingReset = null;
+                Repaint();
+            });
+        }
 
-            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)))
+        private void DrawActionButtons()
+        {
+            EditorGUILayout.BeginHorizontal();
+            GUILayout.FlexibleSpace();
+
+            if (GUILayout.Button("Proceed with Pull (Risky)", GUILayout.Height(30), GUILayout.Width(180)))
             {
-                _callbackInvoked = true;
-                _onCloseCallback?.Invoke(new List<string>());
+                _controller.PerformSafePullWithLock();
                 Close();
             }
-            
             if (GUILayout.Button("Cancel", GUILayout.Height(30)))
             {
                 Close();
             }
-
+            
             EditorGUILayout.EndHorizontal();
         }
-
-        private void OnDestroy()
-        {
-            if (!_callbackInvoked)
-            {
-                _onCloseCallback?.Invoke(null);
-            }
-        }
     }
 }

+ 21 - 0
Assets/Arbitrator/Editor/GUI/GitAssetPostprocessor.cs

@@ -0,0 +1,21 @@
+// Copyright (c) 2025 TerraByte Inc.
+//
+// This script hooks into Unity's asset pipeline to automatically
+// trigger a refresh of the Better Git window when assets change.
+
+using UnityEditor;
+
+namespace Terra.Arbitrator.GUI
+{
+    public class GitAssetPostprocessor : AssetPostprocessor
+    {
+        private static void OnPostprocessAllAssets(string[] _, string[] __, string[] ___, string[] ____)
+        {
+            var window = EditorWindow.GetWindow<ArbitratorWindow>(false, null, false);
+            if (window != null)
+            {
+                window.TriggerAutoRefresh();
+            }
+        }
+    }
+}

+ 3 - 0
Assets/Arbitrator/Editor/GUI/GitAssetPostprocessor.cs.meta

@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5d4351054dcf42ad90fc1e3c926584e0
+timeCreated: 1750763269

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

@@ -58,6 +58,27 @@ namespace Terra.Arbitrator.Services
         }
         
         // --- Promise Executor Implementations ---
+        
+        public static async void GetUpstreamAheadByExecutor(Action<int?> resolve, Action<Exception> reject)
+        {
+            try
+            {
+                await GitCommand.RunAsync(new StringBuilder(), new[] { "fetch" });
+                using var repo = new Repository(ProjectRoot);
+                resolve(repo.Head.TrackingDetails.BehindBy);
+            }
+            catch (Exception ex)
+            {
+                if (ex.Message.Contains("is not tracking a remote branch"))
+                {
+                    resolve(null);
+                }
+                else
+                {
+                    reject(ex);
+                }
+            }
+        }
 
         public static void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
         {

+ 9 - 0
Assets/Arbitrator/Editor/Services/GitService.cs

@@ -24,6 +24,15 @@ namespace Terra.Arbitrator.Services
             return GitExecutors.GetChangeForFile(filePath);
         }
         
+        /// <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()
+        {
+            return new Promise<int?>(GitExecutors.GetUpstreamAheadByExecutor);
+        }
+        
         /// <summary>
         /// Compares the local repository state to the tracked remote branch.
         /// </summary>