// Copyright (c) 2025 TerraByte Inc. // // This script creates a custom Unity Editor window called "Arbitrator" to compare // local Git changes with the tracked remote branch using the LibGit2Sharp library. // // HOW TO USE: // 1. Ensure you have manually installed the LibGit2Sharp v0.27.0 package. // 2. Create an "Editor" folder in your Assets directory if you don't have one. // 3. Save this script as "Arbitrator.cs" inside the "Editor" folder. // 4. In Unity, open the window from the top menu: Terra > Arbitrator. // 5. Click the "Compare with Cloud" button. Results will appear in the console. using System.Linq; using UnityEngine; using UnityEditor; using Terra.Arbitrator.Services; using Terra.Arbitrator.Settings; using System.Collections.Generic; namespace Terra.Arbitrator.GUI { public class ArbitratorWindow : EditorWindow { private List _changes; private string _commitMessage = ""; private Vector2 _scrollPosition; private string _infoMessage; private string _errorMessage; private bool _isLoading; private string _loadingMessage = ""; private GUIStyle _evenRowStyle; private bool _stylesInitialized; [MenuItem("Version Control/Better Git")] public static void ShowWindow() { var window = GetWindow(); window.titleContent = new GUIContent("Better Git", EditorGUIUtility.IconContent("d_UnityEditor.VersionControl").image); } /// /// Called when the window is enabled. This triggers an automatic refresh /// when the window is opened or after scripts are recompiled. /// private void OnEnable() { // Check if we just finished a reset operation and need to show a message. if (SessionState.GetBool(BetterGitStatePersistence.ResetQueueKey, false)) { SessionState.EraseString(BetterGitStatePersistence.ResetQueueKey); _infoMessage = "Multi-file reset complete. Pulling again to confirm..."; // After a successful reset, we should automatically try to pull again. HandlePull(); } else { HandleCompare(); } } /// /// Initializes custom GUIStyles. We do this here to avoid creating new /// styles and textures on every OnGUI call, which is inefficient. /// private void InitializeStyles() { if (_stylesInitialized) return; _evenRowStyle = new GUIStyle(); // Create a 1x1 texture with a subtle gray color var texture = new Texture2D(1, 1); // Use a slightly different color depending on the editor skin (light/dark) var color = EditorGUIUtility.isProSkin ? new Color(0.3f, 0.3f, 0.3f, 0.3f) : new Color(0.8f, 0.8f, 0.8f, 0.5f); texture.SetPixel(0, 0, color); texture.Apply(); _evenRowStyle.normal.background = texture; _stylesInitialized = true; } private void OnGUI() { InitializeStyles(); // --- Top Toolbar --- DrawToolbar(); EditorGUILayout.Space(); // --- Message Display Area --- if (!string.IsNullOrEmpty(_errorMessage)) { EditorGUILayout.HelpBox(_errorMessage, MessageType.Error); } else if (!string.IsNullOrEmpty(_infoMessage) && !_isLoading) { // Only show an info message if not loading, to prevent flicker EditorGUILayout.HelpBox(_infoMessage, MessageType.Info); } // --- Main Content --- else if (!_isLoading && _changes is { Count: > 0 }) { DrawChangesList(); DrawCommitSection(); } } private void ClearMessages() { _errorMessage = null; _infoMessage = null; } /// /// Draws the top menu bar for actions like refreshing. /// private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); EditorGUI.BeginDisabledGroup(_isLoading); // The refresh button is now on the toolbar if (GUILayout.Button(new GUIContent("Refresh", EditorGUIUtility.IconContent("Refresh").image, "Fetches the latest status from the remote."), EditorStyles.toolbarButton, GUILayout.Width(80))) { HandleCompare(); } if (GUILayout.Button(new GUIContent("Pull", EditorGUIUtility.IconContent("CollabPull").image, "Fetch and merge changes from the remote."), EditorStyles.toolbarButton, GUILayout.Width(80))) { HandlePull(); } if (_changes is { Count: > 0 }) { if (GUILayout.Button("Select All", EditorStyles.toolbarButton, GUILayout.Width(80))) { SetAllSelection(true); } if (GUILayout.Button("Deselect All", EditorStyles.toolbarButton, GUILayout.Width(80))) { SetAllSelection(false); } } EditorGUI.EndDisabledGroup(); // This pushes everything that comes after it to the right. GUILayout.FlexibleSpace(); // The loading message will appear on the right side of the toolbar. if (_isLoading) { GUILayout.Label(_loadingMessage); } // Future: Add a dropdown menu for filters or settings here. // If (GUILayout.Button("Filters", EditorStyles.toolbarDropDown)) { ... } EditorGUILayout.EndHorizontal(); } private void SetAllSelection(bool selected) { if (_changes == null) return; foreach (var change in _changes) { change.IsSelectedForCommit = selected; } } private void HandleCompare() { _isLoading = true; _loadingMessage = "Comparing with remote repository..."; ClearMessages(); _changes = null; // First, unstage any files if it's safe to do so. GitService.UnstageAllFilesIfSafe() .Then(wasUnstaged => { if(wasUnstaged) { _infoMessage = "Found and unstaged files for review."; } GitService.CompareLocalToRemote() .Then(result => { _changes = result; if (string.IsNullOrEmpty(_infoMessage) && (_changes == null || _changes.Count == 0)) { _infoMessage = "You are up-to-date! No local changes detected."; } }) .Catch(ex => { _errorMessage = $"Comparison Failed: {ex.Message}"; }) .Finally(() => { // This finally block only runs after the *inner* promise is done. _isLoading = false; Repaint(); }); }) .Catch(ex => { // This catch block handles errors from the Unstage operation. _errorMessage = $"Unstaging Failed: {ex.Message}"; _isLoading = false; Repaint(); }); } /// /// Handles the logic for the Pull button. /// private void HandlePull() { if (_isLoading) return; _isLoading = true; _loadingMessage = "Analyzing for conflicts..."; ClearMessages(); Repaint(); GitService.AnalyzePullConflicts() .Then(analysisResult => { if (analysisResult.HasConflicts) { // Conflicts found! Open the resolution window. _isLoading = false; // Stop loading while modal is open Repaint(); ConflictResolutionWindow.ShowWindow(analysisResult.ConflictingFiles, filesToActOn => { if (filesToActOn == null) return; // User cancelled if (filesToActOn.Any()) HandleResetMultipleFiles(filesToActOn); else HandleForcePull(); // User chose "Pull Anyway" (empty list signal) }); } else { // No conflicts, proceed with a safe pull. _loadingMessage = "No conflicts found. Pulling changes..."; Repaint(); GitService.PerformSafePull() .Then(successMessage => { _infoMessage = successMessage; EditorUtility.DisplayDialog("Pull Complete", successMessage, "OK"); HandleCompare(); // Refresh view after successful pull }) .Catch(ex => { _errorMessage = $"Pull Failed: {ex.Message}"; _isLoading = false; Repaint(); }); } }) .Catch(ex => { _errorMessage = $"Pull Analysis Failed: {ex.Message}"; _isLoading = false; Repaint(); }); } private void HandleResetMultipleFiles(List filePaths) { _infoMessage = $"Starting reset for {filePaths.Count} file(s)... This may trigger script compilation."; Repaint(); SessionState.SetString("BetterGit.ResetQueue", string.Join(";", filePaths)); EditorApplication.delayCall += BetterGitStatePersistence.ContinueInterruptedReset; } private void HandleForcePull() { _isLoading = true; _loadingMessage = "Attempting to pull and create conflicts..."; ClearMessages(); Repaint(); EditorApplication.LockReloadAssemblies(); GitService.ForcePull() .Then(_ => { _infoMessage = "Pull resulted in conflicts. Please resolve them below."; HandleCompare(); }) .Catch(ex => { _errorMessage = $"Forced Pull Failed: {ex.Message}"; _isLoading = false; Repaint(); }) .Finally(() => { EditorApplication.UnlockReloadAssemblies(); AssetDatabase.Refresh(); }); } private void HandleCommitAndPush() { _isLoading = true; _loadingMessage = "Staging, committing, and pushing files..."; ClearMessages(); var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList(); var username = BetterGitSettings.Username; var email = BetterGitSettings.Email; GitService.CommitAndPush(selectedFiles, _commitMessage, username, email) .Then(successMessage => { _infoMessage = successMessage; _commitMessage = ""; // Clear message on success _changes = null; // Clear the list, forcing a refresh HandleCompare(); // Automatically refresh to confirm }) .Catch(ex => { _errorMessage = $"Push Failed: {ex.Message}"; }) .Finally(() => { _isLoading = false; // Repaint is handled by the chained HandleCompare call on success if (!string.IsNullOrEmpty(_errorMessage)) Repaint(); }); } private void HandleResetFile(GitChange change) { if (_isLoading) return; var userConfirmed = EditorUtility.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; _isLoading = true; _loadingMessage = $"Resetting {change.FilePath}..."; ClearMessages(); Repaint(); GitService.ResetFileChanges(change) .Then(successMessage => { _infoMessage = successMessage; HandleCompare(); }) .Catch(ex => { _errorMessage = $"Reset Failed: {ex.Message}"; _isLoading = false; Repaint(); }); } private void HandleDiffFile(GitChange change) { if (_isLoading) return; _isLoading = true; _loadingMessage = $"Launching diff for {change.FilePath}..."; ClearMessages(); Repaint(); GitService.LaunchExternalDiff(change) .Catch(ex => _errorMessage = ex.Message) .Finally(() => { _isLoading = false; HandleCompare(); }); } private void HandleResolveConflict(GitChange change) { if (_isLoading) return; _isLoading = true; _loadingMessage = $"Opening merge tool for {change.FilePath}..."; ClearMessages(); Repaint(); GitService.LaunchMergeTool(change) .Then(successMessage => { _infoMessage = successMessage; }) .Catch(ex => { _errorMessage = $"Resolve Failed: {ex.Message}"; }) .Finally(() => { _isLoading = false; HandleCompare(); // Always refresh to show the new state }); } /// /// Draws the multi-column list of changed files. /// private void DrawChangesList() { // --- Draw Header --- EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); var isConflictMode = _changes.Any(c => c.Status == LibGit2Sharp.ChangeKind.Conflicted); if (!isConflictMode) EditorGUILayout.LabelField("Commit", GUILayout.Width(45)); else GUILayout.Space(45); // Keep layout consistent EditorGUILayout.LabelField("Status", GUILayout.Width(50)); EditorGUILayout.LabelField("File Path"); EditorGUILayout.LabelField("Actions", GUILayout.Width(55)); EditorGUILayout.EndHorizontal(); // --- Draw Scrollable List --- _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true)); for (var i = 0; i < _changes.Count; i++) { var change = _changes[i]; // Use the evenRowStyle for every second row (i % 2 == 0), otherwise use no style. var rowStyle = i % 2 == 0 ? _evenRowStyle : GUIStyle.none; EditorGUILayout.BeginHorizontal(rowStyle); change.IsSelectedForCommit = EditorGUILayout.Toggle(change.IsSelectedForCommit, GUILayout.Width(45)); string status; Color statusColor; string filePathDisplay; switch (change.Status) { case LibGit2Sharp.ChangeKind.Added: status = "[+]"; statusColor = Color.green; filePathDisplay = change.FilePath; break; case LibGit2Sharp.ChangeKind.Deleted: status = "[-]"; statusColor = Color.red; filePathDisplay = change.FilePath; break; case LibGit2Sharp.ChangeKind.Modified: status = "[M]"; statusColor = new Color(1.0f, 0.6f, 0.0f); filePathDisplay = change.FilePath; break; case LibGit2Sharp.ChangeKind.Renamed: status = "[R]"; statusColor = new Color(0.6f, 0.6f, 1.0f); filePathDisplay = $"{change.OldFilePath} -> {change.FilePath}"; break; case LibGit2Sharp.ChangeKind.Conflicted: status = "[C]"; statusColor = Color.magenta; filePathDisplay = change.FilePath; break; case LibGit2Sharp.ChangeKind.Unmodified: case LibGit2Sharp.ChangeKind.Copied: case LibGit2Sharp.ChangeKind.Ignored: case LibGit2Sharp.ChangeKind.Untracked: case LibGit2Sharp.ChangeKind.TypeChanged: case LibGit2Sharp.ChangeKind.Unreadable: default: status = "[?]"; statusColor = Color.white; filePathDisplay = change.FilePath; break; } var originalColor = UnityEngine.GUI.color; UnityEngine.GUI.color = statusColor; EditorGUILayout.LabelField(new GUIContent(status, change.Status.ToString()), GUILayout.Width(50)); UnityEngine.GUI.color = originalColor; EditorGUILayout.LabelField(new GUIContent(filePathDisplay, filePathDisplay)); EditorGUI.BeginDisabledGroup(_isLoading); if (status == "[C]") { if (GUILayout.Button("Resolve", GUILayout.Width(70))) { EditorApplication.delayCall += () => HandleResolveConflict(change); } } else { if (GUILayout.Button("Diff", GUILayout.Width(45))) EditorApplication.delayCall += () => HandleDiffFile(change); } if (GUILayout.Button(new GUIContent("Reset", "Revert changes for this file"), GUILayout.Width(55))) { EditorApplication.delayCall += () => HandleResetFile(change); } EditorGUI.EndDisabledGroup(); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } private void DrawCommitSection() { EditorGUILayout.Space(10); var isConflictMode = _changes.Any(c => c.Status == LibGit2Sharp.ChangeKind.Conflicted); if (isConflictMode) { EditorGUILayout.HelpBox("You must resolve all conflicts before you can commit.", MessageType.Warning); return; } EditorGUILayout.LabelField("Commit & Push", EditorStyles.boldLabel); _commitMessage = EditorGUILayout.TextArea(_commitMessage, GUILayout.Height(60), GUILayout.ExpandWidth(true)); var isPushDisabled = string.IsNullOrWhiteSpace(_commitMessage) || !_changes.Any(c => c.IsSelectedForCommit); EditorGUI.BeginDisabledGroup(isPushDisabled); if (GUILayout.Button("Commit & Push Selected Files", GUILayout.Height(40))) { HandleCommitAndPush(); } EditorGUI.EndDisabledGroup(); if (isPushDisabled) { EditorGUILayout.HelpBox("Please enter a commit message and select at least one file.", MessageType.Warning); } } } }