ArbitratorController.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. // Copyright (c) 2025 TerraByte Inc.
  2. //
  3. // This script acts as the Controller for the ArbitratorWindow. It manages all
  4. // state and business logic, separating it from the UI rendering code in the window.
  5. using System;
  6. using GitMerge;
  7. using System.Linq;
  8. using UnityEditor;
  9. using UnityEngine.Scripting;
  10. using Terra.Arbitrator.Settings;
  11. using Terra.Arbitrator.Services;
  12. using Terra.Arbitrator.Promises;
  13. using System.Collections.Generic;
  14. using UnityEditor.SceneManagement;
  15. using UnityEngine;
  16. namespace Terra.Arbitrator.GUI
  17. {
  18. public enum UserAction { Proceed, SaveAndProceed, Cancel }
  19. public enum SortColumn { Commit, Status, FilePath }
  20. [Preserve]
  21. public class ArbitratorController
  22. {
  23. private List<GitChange> _changes = new();
  24. public IReadOnlyList<GitChange> Changes => _changes;
  25. public string InfoMessage { get; private set; }
  26. public string ErrorMessage { get; private set; }
  27. public bool IsLoading { get; private set; }
  28. public string LoadingMessage { get; private set; } = "";
  29. public bool IsInConflictState { get; private set; }
  30. public bool HasStash { get; private set; }
  31. public int CommitsToPull { get; private set; }
  32. public SortColumn CurrentSortColumn { get; private set; } = SortColumn.FilePath;
  33. public float OperationProgress { get; private set; }
  34. public string OperationProgressMessage { get; private set; }
  35. public IReadOnlyList<string> RemoteBranchList { get; private set; } = new List<string>();
  36. public string CurrentBranchName { get; private set; } = "master";
  37. private readonly Action _requestRepaint;
  38. private readonly Func<string, string, string, string, bool> _displayDialog;
  39. private readonly Func<UserAction> _promptForUnsavedChanges;
  40. /// <summary>
  41. /// Initializes the controller.
  42. /// </summary>
  43. /// <param name="requestRepaint">A callback to the window's Repaint() method.</param>
  44. /// <param name="displayDialog">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
  45. /// <param name="promptForUnsavedChanges">A callback to EditorUtility.DisplayDialog for user confirmations.</param>
  46. public ArbitratorController(Action requestRepaint, Func<string, string, string, string, bool> displayDialog, Func<UserAction> promptForUnsavedChanges)
  47. {
  48. _requestRepaint = requestRepaint;
  49. _displayDialog = displayDialog;
  50. _promptForUnsavedChanges = promptForUnsavedChanges;
  51. }
  52. public void OnEnable()
  53. {
  54. Refresh();
  55. }
  56. public void Refresh()
  57. {
  58. StartOperation("Refreshing status...");
  59. CommitsToPull = 0;
  60. UnstageStep()
  61. .Then(CompareStep)
  62. .Then(FetchUpstreamStep)
  63. .Then(CheckForStashStep)
  64. .Then(FetchBranchDataStep)
  65. .Then(FinalizeRefresh)
  66. .Catch(HandleOperationError)
  67. .Finally(FinishOperation);
  68. }
  69. public void Pull()
  70. {
  71. if (IsLoading) return;
  72. if (CancelOperationIfUnsavedScenes()) return;
  73. if (CommitsToPull > 0)
  74. {
  75. if (!_displayDialog("Confirm Pull", $"There are {CommitsToPull} incoming changes. Are you sure you want to pull?", "Yes, Pull", "Cancel"))
  76. {
  77. return;
  78. }
  79. }
  80. StartOperation("Analyzing for conflicts...");
  81. GitService.AnalyzePullConflicts()
  82. .Then(analysisResult =>
  83. {
  84. FinishOperation();
  85. if (analysisResult.HasConflicts)
  86. {
  87. var conflictingChanges = _changes.Where(c => analysisResult.ConflictingFiles.Contains(c.FilePath)).ToList();
  88. ConflictResolutionWindow.ShowWindow(this, conflictingChanges, new PullConflictSource());
  89. }
  90. else
  91. {
  92. PerformSafePullWithLock();
  93. }
  94. })
  95. .Catch(ex => {
  96. HandleOperationError(ex);
  97. FinishOperation();
  98. })
  99. .Finally(FinishOperation);
  100. }
  101. private void PerformSafePullWithLock()
  102. {
  103. StartOperation("Pulling changes...");
  104. EditorApplication.LockReloadAssemblies();
  105. GitService.PerformSafePull()
  106. .Then(successMessage =>
  107. {
  108. InfoMessage = successMessage;
  109. Refresh();
  110. })
  111. .Catch(ex => {
  112. HandleOperationError(ex);
  113. FinishOperation();
  114. })
  115. .Finally(EditorApplication.UnlockReloadAssemblies);
  116. }
  117. public void CommitAndPush(string commitMessage)
  118. {
  119. if (CancelOperationIfUnsavedScenes()) return;
  120. var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
  121. var username = BetterGitSettings.Username;
  122. var email = BetterGitSettings.Email;
  123. StartOperation("Staging, committing, and pushing files...");
  124. GitService.CommitAndPush(selectedFiles, commitMessage, username, email, OnProgressModified)
  125. .Then(successMessage => {
  126. InfoMessage = successMessage;
  127. Refresh();
  128. })
  129. .Catch(ex => {
  130. HandleOperationError(ex);
  131. FinishOperation();
  132. });
  133. return;
  134. void OnProgressModified(float progress, string message)
  135. {
  136. OperationProgress = progress;
  137. OperationProgressMessage = message;
  138. _requestRepaint?.Invoke();
  139. }
  140. }
  141. public void SetSortColumn(SortColumn newColumn)
  142. {
  143. // If it's already the active column, do nothing.
  144. if (CurrentSortColumn == newColumn) return;
  145. CurrentSortColumn = newColumn;
  146. ApplyGrouping();
  147. _requestRepaint?.Invoke();
  148. }
  149. private void ApplyGrouping()
  150. {
  151. if (_changes == null || !_changes.Any()) return;
  152. _changes = CurrentSortColumn switch
  153. {
  154. SortColumn.Commit => _changes.OrderByDescending(c => c.IsSelectedForCommit).ThenBy(c => c.FilePath).ToList(),
  155. SortColumn.Status => _changes.OrderBy(c => GetStatusSortPriority(c.Status)).ThenBy(c => c.FilePath).ToList(),
  156. SortColumn.FilePath => _changes.OrderBy(c => c.FilePath).ToList(),
  157. _ => _changes
  158. };
  159. }
  160. private static int GetStatusSortPriority(LibGit2Sharp.ChangeKind status)
  161. {
  162. return status switch
  163. {
  164. LibGit2Sharp.ChangeKind.Conflicted => -1, // Always show conflicts on top
  165. LibGit2Sharp.ChangeKind.Modified => 0,
  166. LibGit2Sharp.ChangeKind.Added => 1,
  167. LibGit2Sharp.ChangeKind.Deleted => 2,
  168. LibGit2Sharp.ChangeKind.Renamed => 3,
  169. _ => 99
  170. };
  171. }
  172. public void ResetFile(GitChange change)
  173. {
  174. if (IsLoading) return;
  175. 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");
  176. if (!userConfirmed) return;
  177. StartOperation($"Resetting {change.FilePath}...");
  178. GitService.ResetFileChanges(change)
  179. .Then(successMessage => {
  180. InfoMessage = successMessage;
  181. Refresh();
  182. })
  183. .Catch(ex => {
  184. HandleOperationError(ex);
  185. FinishOperation();
  186. });
  187. }
  188. public void DiffFile(GitChange change)
  189. {
  190. if (IsLoading) return;
  191. StartOperation($"Launching diff for {change.FilePath}...");
  192. GitService.LaunchExternalDiff(change)
  193. .Catch(HandleOperationError)
  194. .Finally(Refresh);
  195. }
  196. public void ResolveConflict(GitChange change)
  197. {
  198. if (IsLoading) return;
  199. StartOperation($"Opening merge tool for {change.FilePath}...");
  200. var fileExtension = System.IO.Path.GetExtension(change.FilePath).ToLower();
  201. if (fileExtension is ".prefab" or ".unity")
  202. {
  203. try
  204. {
  205. GitMergeWindow.ResolveConflict(change.FilePath);
  206. }
  207. catch (Exception e)
  208. {
  209. ErrorMessage = e.Message;
  210. }
  211. Refresh();
  212. return;
  213. }
  214. GitService.LaunchMergeTool(change)
  215. .Then(successMessage => { InfoMessage = successMessage; })
  216. .Catch(HandleOperationError)
  217. .Finally(Refresh);
  218. }
  219. public void ResetSelected()
  220. {
  221. var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
  222. if (!selectedFiles.Any()) return;
  223. var fileList = string.Join("\n - ", selectedFiles.Select(f => f.FilePath));
  224. 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;
  225. var pathsToReset = selectedFiles.Select(c => c.FilePath).ToList();
  226. ResetMultipleFiles(pathsToReset);
  227. }
  228. public void StashSelectedFiles()
  229. {
  230. var selectedFiles = _changes.Where(c => c.IsSelectedForCommit).ToList();
  231. if (!selectedFiles.Any())
  232. {
  233. InfoMessage = "No files selected to stash.";
  234. return;
  235. }
  236. EditorApplication.LockReloadAssemblies();
  237. StartOperation("Checking for existing stash...");
  238. GitService.HasStash()
  239. .Then(hasStash =>
  240. {
  241. FinishOperation();
  242. bool confirmed;
  243. if (hasStash)
  244. {
  245. confirmed = _displayDialog("Overwrite Stash?",
  246. "A 'Better Git Stash' already exists. Do you want to overwrite it with your currently selected files?",
  247. "Yes, Overwrite", "Cancel");
  248. }
  249. else
  250. {
  251. confirmed = _displayDialog("Create Stash?",
  252. $"Are you sure you want to stash the selected {selectedFiles.Count} file(s)?",
  253. "Yes, Stash", "Cancel");
  254. }
  255. if (!confirmed) return;
  256. StartOperation("Stashing selected files...");
  257. GitService.CreateOrOverwriteStash(selectedFiles)
  258. .Then(successMsg => { InfoMessage = successMsg; })
  259. .Catch(HandleOperationError)
  260. .Finally(() =>
  261. {
  262. Refresh();
  263. EditorApplication.delayCall += AssetDatabase.Refresh;
  264. });
  265. })
  266. .Catch(HandleOperationError)
  267. .Finally(() =>
  268. {
  269. FinishOperation();
  270. EditorApplication.delayCall += EditorApplication.UnlockReloadAssemblies;
  271. });
  272. }
  273. public void SetAllSelection(bool selected)
  274. {
  275. if (_changes == null) return;
  276. foreach (var change in _changes.Where(change => change.Status != LibGit2Sharp.ChangeKind.Conflicted))
  277. {
  278. change.IsSelectedForCommit = selected;
  279. }
  280. }
  281. public void SwitchToBranch(string targetBranch)
  282. {
  283. if (IsLoading || targetBranch == CurrentBranchName) return;
  284. if (Changes.Any())
  285. {
  286. if (!_displayDialog("Discard Local Changes?", $"You have local changes. To switch branches, these changes must be discarded.\n\nDiscard changes and switch to '{targetBranch}'?", "Yes, Discard and Switch", "Cancel"))
  287. {
  288. return;
  289. }
  290. StartOperation($"Discarding changes and switching to {targetBranch}...");
  291. GitService.ResetAndSwitchBranch(targetBranch)
  292. .Then(successMsg => { InfoMessage = successMsg; Refresh(); })
  293. .Catch(ex => { HandleOperationError(ex); FinishOperation(); })
  294. .Finally(() => { EditorApplication.delayCall += AssetDatabase.Refresh; });
  295. }
  296. else
  297. {
  298. if (!_displayDialog("Confirm Branch Switch", $"Are you sure you want to switch to branch '{targetBranch}'?", "Yes, Switch", "Cancel"))
  299. {
  300. return;
  301. }
  302. StartOperation($"Switching to {targetBranch}...");
  303. GitService.SwitchBranch(targetBranch)
  304. .Then(successMsg => { InfoMessage = successMsg; Refresh(); })
  305. .Catch(ex => { HandleOperationError(ex); FinishOperation(); })
  306. .Finally(() => { EditorApplication.delayCall += AssetDatabase.Refresh; });
  307. }
  308. }
  309. // --- Private Methods ---
  310. private static IPromise<bool> UnstageStep()
  311. {
  312. return GitService.UnstageAllFilesIfSafe();
  313. }
  314. private IPromise<List<GitChange>> CompareStep(bool wasUnstaged)
  315. {
  316. if (wasUnstaged)
  317. {
  318. InfoMessage = "Found and unstaged files for review.";
  319. }
  320. return GitService.CompareLocalToRemote();
  321. }
  322. private IPromise<int?> FetchUpstreamStep(List<GitChange> changes)
  323. {
  324. _changes = changes;
  325. IsInConflictState = _changes.Any(c => c.Status == LibGit2Sharp.ChangeKind.Conflicted);
  326. return IsInConflictState ? new Promise<int?>((resolve, _) => resolve(0)) : GitService.GetUpstreamAheadBy(OnProgressModified);
  327. void OnProgressModified(float progress, string message)
  328. {
  329. OperationProgress = progress;
  330. OperationProgressMessage = message;
  331. _requestRepaint?.Invoke();
  332. }
  333. }
  334. private IPromise<bool> CheckForStashStep(int? pullCount)
  335. {
  336. CommitsToPull = pullCount ?? 0;
  337. return GitService.HasStash();
  338. }
  339. private IPromise<BranchData> FetchBranchDataStep(bool hasStash)
  340. {
  341. HasStash = hasStash;
  342. OperationProgress = 0f;
  343. OperationProgressMessage = "";
  344. return GitService.GetBranchData();
  345. }
  346. private void FinalizeRefresh(BranchData branchData)
  347. {
  348. CurrentBranchName = branchData.CurrentBranch;
  349. RemoteBranchList = branchData.AllBranches;
  350. ApplyGrouping();
  351. }
  352. // --- Shared Helper Methods ---
  353. private void StartOperation(string loadingMessage)
  354. {
  355. IsLoading = true;
  356. LoadingMessage = loadingMessage;
  357. OperationProgress = 0f;
  358. OperationProgressMessage = "";
  359. ClearMessages();
  360. _changes = null;
  361. _requestRepaint?.Invoke();
  362. }
  363. private void HandleOperationError(Exception ex)
  364. {
  365. ErrorMessage = $"Operation Failed: {ex.Message}";
  366. Debug.LogException(ex);
  367. }
  368. private void FinishOperation()
  369. {
  370. IsLoading = false;
  371. _requestRepaint?.Invoke();
  372. }
  373. private void ClearMessages()
  374. {
  375. ErrorMessage = null;
  376. InfoMessage = null;
  377. }
  378. private void ResetMultipleFiles(List<string> filePaths)
  379. {
  380. EditorApplication.LockReloadAssemblies();
  381. StartOperation($"Resetting {filePaths.Count} file(s)...");
  382. IPromise<string> promiseChain = new Promise<string>((resolve, _) => resolve(""));
  383. foreach (var path in filePaths)
  384. {
  385. promiseChain = promiseChain.Then(_ =>
  386. {
  387. var change = GitService.GetChangeForFile(path);
  388. return change != null ? GitService.ResetFileChanges(change) : new Promise<string>((res, _) => res(""));
  389. });
  390. }
  391. promiseChain
  392. .Then(_ =>
  393. {
  394. InfoMessage = $"Successfully reset {filePaths.Count} file(s).";
  395. Refresh();
  396. })
  397. .Catch(ex =>
  398. {
  399. HandleOperationError(ex);
  400. FinishOperation();
  401. })
  402. .Finally(() =>
  403. {
  404. EditorApplication.delayCall += () =>
  405. {
  406. EditorApplication.UnlockReloadAssemblies();
  407. AssetDatabase.Refresh();
  408. };
  409. });
  410. }
  411. public void ResolveConflicts(List<GitChange> resolutions, IConflictSource source)
  412. {
  413. StartOperation("Resolving conflicts...");
  414. EditorApplication.LockReloadAssemblies();
  415. source.Resolve(resolutions)
  416. .Then(successMessage =>
  417. {
  418. InfoMessage = successMessage;
  419. Refresh();
  420. })
  421. .Catch(HandleOperationError)
  422. .Finally(() =>
  423. {
  424. EditorApplication.delayCall += () =>
  425. {
  426. EditorApplication.UnlockReloadAssemblies();
  427. AssetDatabase.Refresh();
  428. };
  429. });
  430. }
  431. public void ShowStashedChangesWindow()
  432. {
  433. StartOperation("Loading stashed files...");
  434. GitService.GetStashedFiles()
  435. .Then(stashedFiles =>
  436. {
  437. if (stashedFiles.Any())
  438. {
  439. InfoMessage = "Fetching files from the stash.";
  440. StashedChangesWindow.ShowWindow(this, stashedFiles, Refresh);
  441. }
  442. else
  443. {
  444. InfoMessage = "No files found in the stash.";
  445. }
  446. })
  447. .Catch(HandleOperationError)
  448. .Finally(FinishOperation);
  449. }
  450. public void DiscardStash()
  451. {
  452. StartOperation("Discarding stash...");
  453. GitService.DropStash()
  454. .Then(successMessage =>
  455. {
  456. InfoMessage = successMessage;
  457. })
  458. .Catch(HandleOperationError)
  459. .Finally(Refresh);
  460. }
  461. public void DiffStashedFile(GitChange change)
  462. {
  463. GitService.DiffStashedFile(change)
  464. .Catch(ex =>
  465. {
  466. var res = _displayDialog("Stash Diff Error", $"Error diffing '{change.FilePath}': {ex.Message}", "Uh-oh", "Ok");
  467. if (!res) return;
  468. Debug.Log($"Error diffing '{change.FilePath}'");
  469. Debug.LogException(ex);
  470. });
  471. }
  472. public void ApplyStash()
  473. {
  474. StartOperation("Analyzing stash for conflicts...");
  475. GitService.AnalyzeStashConflicts()
  476. .Then(analysisResult =>
  477. {
  478. FinishOperation();
  479. if (analysisResult.HasConflicts)
  480. {
  481. var conflictSource = new StashConflictSource();
  482. GitService.GetStashedFiles().Then(stashedFiles =>
  483. {
  484. var conflictingChanges = stashedFiles
  485. .Where(sf => analysisResult.ConflictingFiles.Contains(sf.FilePath))
  486. .ToList();
  487. ConflictResolutionWindow.ShowWindow(this, conflictingChanges, conflictSource);
  488. });
  489. }
  490. else
  491. {
  492. StartOperation("Applying stash...");
  493. var resolutions = new List<GitChange>();
  494. var source = new StashConflictSource();
  495. source.Resolve(resolutions)
  496. .Then(successMsg => { InfoMessage = successMsg; })
  497. .Catch(HandleOperationError)
  498. .Finally(Refresh);
  499. }
  500. })
  501. .Catch(HandleOperationError)
  502. .Finally(() =>
  503. {
  504. FinishOperation();
  505. Refresh();
  506. EditorApplication.delayCall += AssetDatabase.Refresh;
  507. });
  508. }
  509. private bool CancelOperationIfUnsavedScenes()
  510. {
  511. var isAnySceneDirty = false;
  512. for (var i = 0; i < EditorSceneManager.sceneCount; i++)
  513. {
  514. var scene = EditorSceneManager.GetSceneAt(i);
  515. if (!scene.isDirty) continue;
  516. isAnySceneDirty = true;
  517. break;
  518. }
  519. if (!isAnySceneDirty)
  520. {
  521. return false;
  522. }
  523. var userChoice = _promptForUnsavedChanges();
  524. switch (userChoice)
  525. {
  526. case UserAction.SaveAndProceed:
  527. EditorSceneManager.SaveOpenScenes();
  528. return false;
  529. case UserAction.Proceed:
  530. return false;
  531. case UserAction.Cancel:
  532. default:
  533. return true;
  534. }
  535. }
  536. }
  537. }