GitExecutors.cs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. // Copyright (c) 2025 TerraByte Inc.
  2. //
  3. // A new internal class that contains the concrete implementation logic for
  4. // all Git operations. This separates the "how" from the "what" defined
  5. // in the public GitService API.
  6. using System;
  7. using CliWrap;
  8. using System.IO;
  9. using System.Linq;
  10. using UnityEngine;
  11. using LibGit2Sharp;
  12. using System.Text;
  13. using System.Globalization;
  14. using System.ComponentModel;
  15. using UnityEngine.Scripting;
  16. using System.Threading.Tasks;
  17. using Terra.Arbitrator.Settings;
  18. using System.Collections.Generic;
  19. namespace Terra.Arbitrator.Services
  20. {
  21. /// <summary>
  22. /// A simple data container for branch information.
  23. /// </summary>
  24. [Preserve]
  25. public class BranchData
  26. {
  27. public string CurrentBranch { get; set; }
  28. public List<string> AllBranches { get; set; }
  29. }
  30. /// <summary>
  31. /// Contains the promise executor methods for all Git operations.
  32. /// This is an internal implementation detail and is not exposed publicly.
  33. /// </summary>
  34. [Preserve]
  35. internal static class GitExecutors
  36. {
  37. private static string _projectRoot;
  38. private static string ProjectRoot => _projectRoot ??= Directory.GetParent(Application.dataPath)?.FullName;
  39. public static void GetBranchDataExecutor(Action<BranchData> resolve, Action<Exception> reject)
  40. {
  41. try
  42. {
  43. using var repo = new Repository(ProjectRoot);
  44. var data = new BranchData
  45. {
  46. CurrentBranch = repo.Head.FriendlyName,
  47. // Get all local and remote branches, then get their friendly names and remove duplicates.
  48. AllBranches = repo.Branches
  49. .Where(b => !b.FriendlyName.Contains("HEAD")) // Filter out the detached HEAD entry
  50. .Select(b => b.FriendlyName.Replace("origin/", "")) // Clean up remote names
  51. .Distinct()
  52. .OrderBy(name => name)
  53. .ToList()
  54. };
  55. resolve(data);
  56. }
  57. catch(Exception ex)
  58. {
  59. reject(ex);
  60. }
  61. }
  62. public static async void SwitchBranchExecutor(Action<string> resolve, Action<Exception> reject, string branchName)
  63. {
  64. try
  65. {
  66. var log = new StringBuilder();
  67. await GitCommand.RunAsync(log, new[] { "checkout", branchName });
  68. resolve($"Successfully switched to branch '{branchName}'.");
  69. }
  70. catch (Exception ex)
  71. {
  72. reject(ex);
  73. }
  74. }
  75. public static async void ResetAndSwitchBranchExecutor(Action<string> resolve, Action<Exception> reject, string branchName)
  76. {
  77. try
  78. {
  79. var log = new StringBuilder();
  80. await GitCommand.RunAsync(log, new[] { "reset", "--hard", "HEAD" });
  81. await GitCommand.RunAsync(log, new[] { "clean", "-fd" });
  82. await GitCommand.RunAsync(log, new[] { "checkout", branchName });
  83. resolve($"Discarded local changes and switched to branch '{branchName}'.");
  84. }
  85. catch (Exception ex)
  86. {
  87. reject(ex);
  88. }
  89. }
  90. /// <summary>
  91. /// Synchronous helper to get a GitChange object for a single file.
  92. /// This is public so it can be called by the GitService wrapper.
  93. /// </summary>
  94. public static GitChange GetChangeForFile(string filePath)
  95. {
  96. try
  97. {
  98. using var repo = new Repository(ProjectRoot);
  99. if (repo.Index.Conflicts.Any(c => c.Ours.Path == filePath))
  100. {
  101. return new GitChange(filePath, null, ChangeKind.Conflicted);
  102. }
  103. var statusEntry = repo.RetrieveStatus(filePath);
  104. return statusEntry switch
  105. {
  106. FileStatus.NewInWorkdir or FileStatus.NewInIndex => new GitChange(filePath, null, ChangeKind.Added),
  107. FileStatus.ModifiedInWorkdir or FileStatus.ModifiedInIndex => new GitChange(filePath, null, ChangeKind.Modified),
  108. FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => new GitChange(filePath, null, ChangeKind.Deleted),
  109. FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex =>
  110. new GitChange(filePath, null, ChangeKind.Renamed),
  111. _ => null
  112. };
  113. }
  114. catch { return null; } // Suppress errors if repo is in a unique state
  115. }
  116. // --- Promise Executor Implementations ---
  117. public static async void GetUpstreamAheadByExecutor(Action<int?> resolve, Action<Exception> reject, Action<float, string> onProgress)
  118. {
  119. try
  120. {
  121. var progressReporter = new Progress<string>(line => ParseProgress(line, onProgress));
  122. await GitCommand.RunAsync(new StringBuilder(), progressReporter, new[] { "fetch", "--progress" });
  123. using var repo = new Repository(ProjectRoot);
  124. resolve(repo.Head.TrackingDetails.BehindBy);
  125. }
  126. catch (Exception ex)
  127. {
  128. if (ex.Message.Contains("is not tracking a remote branch"))
  129. {
  130. resolve(null);
  131. }
  132. else
  133. {
  134. reject(ex);
  135. }
  136. }
  137. }
  138. public static void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
  139. {
  140. try
  141. {
  142. var changes = new List<GitChange>();
  143. using var repo = new Repository(ProjectRoot);
  144. var conflictedPaths = new HashSet<string>(repo.Index.Conflicts.Select(c => c.Ours.Path));
  145. var statusOptions = new StatusOptions
  146. {
  147. IncludeUntracked = true,
  148. RecurseUntrackedDirs = true,
  149. DetectRenamesInIndex = true,
  150. DetectRenamesInWorkDir = true
  151. };
  152. foreach (var entry in repo.RetrieveStatus(statusOptions))
  153. {
  154. if (conflictedPaths.Contains(entry.FilePath))
  155. {
  156. if (changes.All(c => c.FilePath != entry.FilePath))
  157. {
  158. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Conflicted));
  159. }
  160. continue;
  161. }
  162. switch(entry.State)
  163. {
  164. case FileStatus.NewInWorkdir:
  165. case FileStatus.NewInIndex:
  166. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Added));
  167. break;
  168. case FileStatus.ModifiedInWorkdir:
  169. case FileStatus.ModifiedInIndex:
  170. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Modified));
  171. break;
  172. case FileStatus.DeletedFromWorkdir:
  173. case FileStatus.DeletedFromIndex:
  174. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Deleted));
  175. break;
  176. case FileStatus.RenamedInWorkdir:
  177. case FileStatus.RenamedInIndex:
  178. var renameDetails = entry.HeadToIndexRenameDetails ?? entry.IndexToWorkDirRenameDetails;
  179. changes.Add(renameDetails != null ? new GitChange(renameDetails.NewFilePath, renameDetails.OldFilePath, ChangeKind.Renamed)
  180. : new GitChange(entry.FilePath, "Unknown", ChangeKind.Renamed));
  181. break;
  182. }
  183. }
  184. resolve(changes);
  185. }
  186. catch (Exception ex)
  187. {
  188. reject(ex);
  189. }
  190. }
  191. public static async void CommitAndPushExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changesToCommit, string commitMessage, string username, string email, Action<float, string> onProgress)
  192. {
  193. try
  194. {
  195. if (string.IsNullOrWhiteSpace(email))
  196. {
  197. throw new Exception("Author email is missing. Please set your email address in Project Settings > Better Git.");
  198. }
  199. using (var repo = new Repository(ProjectRoot))
  200. {
  201. var remote = repo.Network.Remotes["origin"];
  202. if (remote == null) throw new Exception("No remote named 'origin' found.");
  203. var fetchOptions = new FetchOptions { CertificateCheck = (_, _, _) => true };
  204. Commands.Fetch(repo, remote.Name, Array.Empty<string>(), fetchOptions, "Arbitrator pre-push fetch");
  205. var trackingDetails = repo.Head.TrackingDetails;
  206. if (trackingDetails.BehindBy > 0)
  207. {
  208. throw new Exception($"Push aborted. There are {trackingDetails.BehindBy.Value} incoming changes on the remote. Please pull first.");
  209. }
  210. var pathsToStage = new List<string>();
  211. foreach (var change in changesToCommit)
  212. {
  213. switch (change.Status)
  214. {
  215. case ChangeKind.Deleted:
  216. Commands.Remove(repo, change.FilePath);
  217. break;
  218. case ChangeKind.Renamed:
  219. Commands.Remove(repo, change.OldFilePath);
  220. pathsToStage.Add(change.FilePath);
  221. break;
  222. default:
  223. pathsToStage.Add(change.FilePath);
  224. break;
  225. }
  226. }
  227. if (pathsToStage.Any()) Commands.Stage(repo, pathsToStage);
  228. var status = repo.RetrieveStatus();
  229. if (!status.IsDirty) throw new Exception("No effective changes were staged to commit.");
  230. var author = new Signature(username, email, DateTimeOffset.Now);
  231. repo.Commit(commitMessage, author, author);
  232. }
  233. var progressReporter = new Progress<string>(line => ParseProgress(line, onProgress));
  234. await GitCommand.RunAsync(new StringBuilder(), progressReporter, new[] { "push", "--progress" }, 0, 141);
  235. resolve("Successfully committed and pushed changes!");
  236. }
  237. catch (Exception ex)
  238. {
  239. var errorMessage = ex.InnerException?.Message ?? ex.Message;
  240. reject(new Exception(errorMessage));
  241. }
  242. }
  243. private static void ParseProgress(string line, Action<float, string> onProgress)
  244. {
  245. if (onProgress == null || string.IsNullOrWhiteSpace(line)) return;
  246. line = line.Trim();
  247. var parts = line.Split(new[] { ':' }, 2);
  248. if (parts.Length < 2) return;
  249. var action = parts[0];
  250. var progressPart = parts[1];
  251. var percentIndex = progressPart.IndexOf('%');
  252. if (percentIndex == -1) return;
  253. var percentString = progressPart[..percentIndex].Trim();
  254. if (!float.TryParse(percentString, NumberStyles.Any, CultureInfo.InvariantCulture, out var percentage)) return;
  255. var progressValue = percentage / 100.0f;
  256. onProgress(progressValue, $"{action}...");
  257. }
  258. public static void ResetFileExecutor(Action<string> resolve, Action<Exception> reject, GitChange changeToReset)
  259. {
  260. try
  261. {
  262. using var repo = new Repository(ProjectRoot);
  263. switch (changeToReset.Status)
  264. {
  265. case ChangeKind.Added:
  266. {
  267. Commands.Unstage(repo, changeToReset.FilePath);
  268. var fullPath = Path.Combine(ProjectRoot, changeToReset.FilePath);
  269. if (File.Exists(fullPath))
  270. {
  271. File.Delete(fullPath);
  272. }
  273. break;
  274. }
  275. case ChangeKind.Renamed:
  276. {
  277. Commands.Unstage(repo, changeToReset.FilePath);
  278. var newFullPath = Path.Combine(ProjectRoot, changeToReset.FilePath);
  279. if (File.Exists(newFullPath)) File.Delete(newFullPath);
  280. repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.OldFilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  281. break;
  282. }
  283. default:
  284. repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.FilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  285. break;
  286. }
  287. resolve($"Successfully reset changes for '{changeToReset.FilePath}'");
  288. }
  289. catch (Exception ex)
  290. {
  291. reject(ex);
  292. }
  293. }
  294. public static async void LaunchExternalDiffExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
  295. {
  296. string fileAPath = null; // Before
  297. string fileBPath = null; // After
  298. try
  299. {
  300. using var repo = new Repository(ProjectRoot);
  301. string GetFileContentFromHead(string path)
  302. {
  303. var blob = repo.Head.Tip[path]?.Target as Blob;
  304. return blob?.GetContentText() ?? "";
  305. }
  306. string CreateTempFile(string originalPath, string content)
  307. {
  308. var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(originalPath));
  309. File.WriteAllText(tempPath, content);
  310. return tempPath;
  311. }
  312. switch (change.Status)
  313. {
  314. case ChangeKind.Added:
  315. fileAPath = CreateTempFile(change.FilePath, "");
  316. fileBPath = Path.Combine(ProjectRoot, change.FilePath);
  317. break;
  318. case ChangeKind.Deleted:
  319. fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
  320. fileBPath = CreateTempFile(change.FilePath, "");
  321. break;
  322. case ChangeKind.Renamed:
  323. fileAPath = CreateTempFile(change.OldFilePath, GetFileContentFromHead(change.OldFilePath));
  324. fileBPath = Path.Combine(ProjectRoot, change.FilePath);
  325. break;
  326. default: // Modified
  327. fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
  328. fileBPath = Path.Combine(ProjectRoot, change.FilePath);
  329. break;
  330. }
  331. await Cli.Wrap(GitCommand.FindVsCodeExecutable())
  332. .WithArguments(args => args.Add("--diff").Add(fileAPath).Add(fileBPath))
  333. .ExecuteAsync();
  334. resolve("Launched external diff tool.");
  335. }
  336. catch(Win32Exception ex)
  337. {
  338. reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
  339. }
  340. catch(Exception ex)
  341. {
  342. reject(ex);
  343. }
  344. finally
  345. {
  346. try
  347. {
  348. if (fileAPath != null && fileAPath.Contains(Path.GetTempPath()) && File.Exists(fileAPath)) File.Delete(fileAPath);
  349. if (fileBPath != null && fileBPath.Contains(Path.GetTempPath()) && File.Exists(fileBPath)) File.Delete(fileBPath);
  350. }
  351. catch(Exception cleanupEx)
  352. {
  353. Debug.LogError($"Failed to clean up temporary diff files: {cleanupEx.Message}");
  354. }
  355. }
  356. }
  357. public static void FileLevelConflictCheckExecutor(Action<PullAnalysisResult> resolve, Action<Exception> reject)
  358. {
  359. try
  360. {
  361. using var repo = new Repository(ProjectRoot);
  362. var result = AnalyzePullConflictsInternal(repo).Result;
  363. resolve(result);
  364. }
  365. catch (Exception ex)
  366. {
  367. reject(ex);
  368. }
  369. }
  370. private static Task<PullAnalysisResult> AnalyzePullConflictsInternal(Repository repo)
  371. {
  372. var remote = repo.Network.Remotes["origin"];
  373. if (remote == null) throw new Exception("No remote named 'origin' was found.");
  374. Commands.Fetch(repo, remote.Name, Array.Empty<string>(), new FetchOptions { CertificateCheck = (_,_,_) => true }, null);
  375. var localBranch = repo.Head;
  376. var remoteBranch = repo.Head.TrackedBranch;
  377. if (remoteBranch == null) throw new Exception("Current branch is not tracking a remote branch.");
  378. var mergeBase = repo.ObjectDatabase.FindMergeBase(localBranch.Tip, remoteBranch.Tip);
  379. if (mergeBase == null) throw new Exception("Could not find a common ancestor.");
  380. var theirChanges = new HashSet<string>(repo.Diff.Compare<TreeChanges>(mergeBase.Tree, remoteBranch.Tip.Tree).Select(c => c.Path));
  381. var ourChanges = new HashSet<string>(repo.Diff.Compare<TreeChanges>(mergeBase.Tree, localBranch.Tip.Tree).Select(c => c.Path));
  382. foreach (var statusEntry in repo.RetrieveStatus()) ourChanges.Add(statusEntry.FilePath);
  383. return Task.FromResult(new PullAnalysisResult(ourChanges.Where(theirChanges.Contains).ToList()));
  384. }
  385. public static void SafePullExecutor(Action<string> resolve, Action<Exception> reject)
  386. {
  387. try
  388. {
  389. using var repo = new Repository(ProjectRoot);
  390. var signature = new Signature("Better Git Tool", "bettergit@letsterra.com", DateTimeOffset.Now);
  391. var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (_,_,_) => true } };
  392. var mergeResult = Commands.Pull(repo, signature, pullOptions);
  393. resolve(mergeResult.Status == MergeStatus.UpToDate ? "Already up-to-date." : $"Pull successful. Status: {mergeResult.Status}");
  394. }
  395. catch (Exception ex)
  396. {
  397. reject(ex);
  398. }
  399. }
  400. public static async void ForcePullExecutor(Action<string> resolve, Action<Exception> reject)
  401. {
  402. var log = new StringBuilder();
  403. var hasStashed = false;
  404. try
  405. {
  406. using (var repo = new Repository(ProjectRoot))
  407. {
  408. if (repo.RetrieveStatus().IsDirty)
  409. {
  410. await GitCommand.RunAsync(log, new[] { "stash", "push", "-u", "-m", "BetterGit-WIP-Pull" }, 0, 141);
  411. hasStashed = true;
  412. }
  413. }
  414. await GitCommand.RunAsync(log, new[] { "pull", "--no-rebase" }, 0, 1, 141);
  415. if (hasStashed)
  416. {
  417. await GitCommand.RunAsync(log, new[] { "stash", "pop" }, 0, 1, 141);
  418. await GitCommand.RunAsync(log, new[] { "stash", "drop" }, 0, 141);
  419. }
  420. resolve(log.ToString());
  421. }
  422. catch (Exception ex)
  423. {
  424. if (hasStashed)
  425. {
  426. try
  427. {
  428. await GitCommand.RunAsync(new StringBuilder(), new[] { "stash", "pop" }, 0, 1, 141);
  429. }
  430. catch (Exception exception)
  431. {
  432. log.AppendLine($"Fatal Error trying to pop stash after a failed pull: {exception.Message}");
  433. }
  434. }
  435. log.AppendLine("\n--- PULL FAILED ---");
  436. log.AppendLine(ex.ToString());
  437. reject(new Exception(log.ToString()));
  438. }
  439. }
  440. public static async void LaunchMergeToolExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
  441. {
  442. try
  443. {
  444. if (change.FilePath == null)
  445. {
  446. reject(new Exception("Could not find file path."));
  447. return;
  448. }
  449. var fileExtension = Path.GetExtension(change.FilePath).ToLower();
  450. if (fileExtension is ".prefab" or ".unity")
  451. {
  452. reject(new Exception("Cannot auto-resolve conflicts for binary files. Please use an external merge tool."));
  453. return;
  454. }
  455. await GitCommand.RunAsync(new StringBuilder(), new[] { GitCommand.FindVsCodeExecutable(), "--wait", change.FilePath }, 0, 141);
  456. var fullPath = Path.Combine(ProjectRoot, change.FilePath);
  457. var fileContent = await File.ReadAllTextAsync(fullPath);
  458. if (fileContent.Contains("<<<<<<<"))
  459. {
  460. resolve($"Conflict in '{change.FilePath}' was not resolved. Please try again.");
  461. return;
  462. }
  463. await GitCommand.RunAsync(new StringBuilder(), new[] { "add", change.FilePath });
  464. await GitCommand.RunAsync(new StringBuilder(), new[] { "reset", "HEAD", change.FilePath });
  465. resolve($"Successfully resolved conflict in '{change.FilePath}'. The file is now modified and ready for review.");
  466. }
  467. catch (Win32Exception ex)
  468. {
  469. reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
  470. }
  471. catch (Exception ex)
  472. {
  473. reject(ex);
  474. }
  475. }
  476. public static async void UnstageAllFilesIfSafeExecutor(Action<bool> resolve, Action<Exception> reject)
  477. {
  478. try
  479. {
  480. using var repo = new Repository(ProjectRoot);
  481. if (repo.Index.Conflicts.Any())
  482. {
  483. resolve(false);
  484. return;
  485. }
  486. var stagedFiles = repo.RetrieveStatus().Count(s => s.State is
  487. FileStatus.NewInIndex or
  488. FileStatus.ModifiedInIndex or
  489. FileStatus.DeletedFromIndex or
  490. FileStatus.RenamedInIndex or
  491. FileStatus.TypeChangeInIndex);
  492. if (stagedFiles == 0)
  493. {
  494. resolve(false);
  495. return;
  496. }
  497. await GitCommand.RunAsync(new StringBuilder(), new[] { "reset" });
  498. resolve(true);
  499. }
  500. catch (Exception ex)
  501. {
  502. reject(ex);
  503. }
  504. }
  505. public static async void ResetAllChangesExecutor(Action<string> resolve, Action<Exception> reject)
  506. {
  507. try
  508. {
  509. var log = new StringBuilder();
  510. await GitCommand.RunAsync(log, new[] { "reset", "--hard", "HEAD" });
  511. await GitCommand.RunAsync(log, new[] { "clean", "-fd" });
  512. resolve("Successfully discarded all local changes.");
  513. }
  514. catch (Exception ex)
  515. {
  516. reject(ex);
  517. }
  518. }
  519. }
  520. }