GitService.cs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. // Copyright (c) 2025 TerraByte Inc.
  2. //
  3. // This script contains the core business logic for interacting with the Git
  4. // repository. All public methods are asynchronous and return a promise.
  5. using System;
  6. using CliWrap;
  7. using System.IO;
  8. using System.Linq;
  9. using UnityEngine;
  10. using System.Text;
  11. using LibGit2Sharp;
  12. using System.ComponentModel;
  13. using System.Threading.Tasks;
  14. using Terra.Arbitrator.Settings;
  15. using Terra.Arbitrator.Promises;
  16. using System.Collections.Generic;
  17. using System.Runtime.InteropServices;
  18. using Debug = UnityEngine.Debug;
  19. namespace Terra.Arbitrator.Services
  20. {
  21. public static class GitService
  22. {
  23. // Public method that returns the promise
  24. public static IPromise<List<GitChange>> CompareLocalToRemote()
  25. {
  26. return new Promise<List<GitChange>>(GetLocalStatusExecutor);
  27. }
  28. // Public method that returns the promise
  29. public static IPromise<string> CommitAndPush(List<GitChange> changesToCommit, string commitMessage, string username, string email)
  30. {
  31. // Use a lambda here to pass arguments to the executor method
  32. return new Promise<string>((resolve, reject) =>
  33. CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage, username, email));
  34. }
  35. // Creates a promise to revert a single file to its HEAD revision.
  36. public static IPromise<string> ResetFileChanges(GitChange changeToReset)
  37. {
  38. return new Promise<string>((resolve, reject) =>
  39. ResetFileExecutor(resolve, reject, changeToReset));
  40. }
  41. // Creates a promise to launch an external diff tool (VS Code).
  42. public static IPromise<string> LaunchExternalDiff(GitChange change)
  43. {
  44. return new Promise<string>((resolve, reject) => LaunchExternalDiffExecutor(resolve, reject, change));
  45. }
  46. // Performs a non-destructive analysis of a potential pull.
  47. public static IPromise<PullAnalysisResult> AnalyzePullConflicts()
  48. {
  49. return new Promise<PullAnalysisResult>(FileLevelConflictCheckExecutor);
  50. }
  51. // Performs a pull, assuming analysis has already confirmed it's safe.
  52. public static IPromise<string> PerformSafePull()
  53. {
  54. return new Promise<string>(SafePullExecutor);
  55. }
  56. // Creates a promise to perform a pull that may result in conflicts.
  57. public static IPromise<string> ForcePull()
  58. {
  59. return new Promise<string>(ForcePullExecutor);
  60. }
  61. // Creates a promise to launch an external merge tool for a conflicted file.
  62. public static IPromise<string> LaunchMergeTool(GitChange change)
  63. {
  64. return new Promise<string>((resolve, reject) => LaunchMergeToolExecutor(resolve, reject, change));
  65. }
  66. /// <summary>
  67. /// Creates a promise to unstage all files if the repository is in a clean, non-conflicted state.
  68. /// </summary>
  69. public static IPromise<bool> UnstageAllFilesIfSafe()
  70. {
  71. return new Promise<bool>(UnstageAllFilesIfSafeExecutor);
  72. }
  73. /// <summary>
  74. /// Synchronous helper to get a GitChange object for a single file.
  75. /// Needed by the static persistence script which cannot easily use promises.
  76. /// </summary>
  77. public static GitChange GetChangeForFile(string filePath)
  78. {
  79. try
  80. {
  81. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  82. using var repo = new Repository(projectRoot);
  83. var statusEntry = repo.RetrieveStatus(filePath);
  84. // Determine ChangeKind from FileStatus
  85. return statusEntry switch
  86. {
  87. FileStatus.NewInWorkdir or FileStatus.NewInIndex => new GitChange(filePath, null, ChangeKind.Added),
  88. FileStatus.ModifiedInWorkdir or FileStatus.ModifiedInIndex => new GitChange(filePath, null, ChangeKind.Modified),
  89. FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => new GitChange(filePath, null, ChangeKind.Deleted),
  90. FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex =>
  91. // Getting the old path from a single status entry is complex,
  92. // for reset purposes, treating it as modified is safe enough.
  93. new GitChange(filePath, null, ChangeKind.Renamed),
  94. _ => null
  95. };
  96. }
  97. catch { return null; } // Suppress errors if repo is in a unique state
  98. }
  99. /// <summary>
  100. /// Gets the status of the local repository, showing unstaged changes.
  101. /// This is the equivalent of 'git status'.
  102. /// </summary>
  103. private static void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
  104. {
  105. try
  106. {
  107. var changes = new List<GitChange>();
  108. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  109. using var repo = new Repository(projectRoot);
  110. var conflictedPaths = new HashSet<string>(repo.Index.Conflicts.Select(c => c.Ours.Path));
  111. var statusOptions = new StatusOptions
  112. {
  113. IncludeUntracked = true,
  114. RecurseUntrackedDirs = true,
  115. DetectRenamesInIndex = true,
  116. DetectRenamesInWorkDir = true
  117. };
  118. foreach (var entry in repo.RetrieveStatus(statusOptions))
  119. {
  120. if (conflictedPaths.Contains(entry.FilePath))
  121. {
  122. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Conflicted));
  123. continue;
  124. }
  125. switch(entry.State)
  126. {
  127. case FileStatus.NewInWorkdir:
  128. case FileStatus.NewInIndex:
  129. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Added));
  130. break;
  131. case FileStatus.ModifiedInWorkdir:
  132. case FileStatus.ModifiedInIndex:
  133. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Modified));
  134. break;
  135. case FileStatus.DeletedFromWorkdir:
  136. case FileStatus.DeletedFromIndex:
  137. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Deleted));
  138. break;
  139. case FileStatus.RenamedInWorkdir:
  140. case FileStatus.RenamedInIndex:
  141. var renameDetails = entry.HeadToIndexRenameDetails ?? entry.IndexToWorkDirRenameDetails;
  142. changes.Add(renameDetails != null ? new GitChange(renameDetails.NewFilePath, renameDetails.OldFilePath, ChangeKind.Renamed)
  143. // Fallback for safety, though this path should rarely be hit with correct options.
  144. : new GitChange(entry.FilePath, "Unknown", ChangeKind.Renamed));
  145. break;
  146. case FileStatus.Nonexistent:
  147. case FileStatus.Unaltered:
  148. case FileStatus.TypeChangeInIndex:
  149. case FileStatus.TypeChangeInWorkdir:
  150. case FileStatus.Unreadable:
  151. case FileStatus.Ignored:
  152. case FileStatus.Conflicted:
  153. default:
  154. break;
  155. }
  156. }
  157. resolve(changes);
  158. }
  159. catch (Exception ex)
  160. {
  161. Debug.LogException(ex);
  162. reject(ex);
  163. }
  164. }
  165. private static void FileLevelConflictCheckExecutor(Action<PullAnalysisResult> resolve, Action<Exception> reject)
  166. {
  167. try
  168. {
  169. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  170. using var repo = new Repository(projectRoot);
  171. var result = AnalyzePullConflicts(repo).Result; // .Result is safe here as it's wrapped in a promise
  172. resolve(result);
  173. }
  174. catch (Exception ex)
  175. {
  176. reject(ex);
  177. }
  178. }
  179. private static Task<PullAnalysisResult> AnalyzePullConflicts(Repository repo)
  180. {
  181. var remote = repo.Network.Remotes["origin"];
  182. if (remote == null) throw new Exception("No remote named 'origin' was found.");
  183. Commands.Fetch(repo, remote.Name, Array.Empty<string>(), new FetchOptions { CertificateCheck = (certificate, valid, host) => true }, null);
  184. var localBranch = repo.Head;
  185. var remoteBranch = repo.Head.TrackedBranch;
  186. if (remoteBranch == null) throw new Exception("Current branch is not tracking a remote branch.");
  187. var mergeBase = repo.ObjectDatabase.FindMergeBase(localBranch.Tip, remoteBranch.Tip);
  188. if (mergeBase == null) throw new Exception("Could not find a common ancestor.");
  189. var theirChanges = new HashSet<string>();
  190. var remoteDiff = repo.Diff.Compare<TreeChanges>(mergeBase.Tree, remoteBranch.Tip.Tree);
  191. foreach (var change in remoteDiff) theirChanges.Add(change.Path);
  192. var ourChanges = new HashSet<string>();
  193. var localCommitDiff = repo.Diff.Compare<TreeChanges>(mergeBase.Tree, localBranch.Tip.Tree);
  194. foreach (var change in localCommitDiff) ourChanges.Add(change.Path);
  195. var localStatus = repo.RetrieveStatus();
  196. foreach (var statusEntry in localStatus) ourChanges.Add(statusEntry.FilePath);
  197. var conflictingFiles = ourChanges.Where(theirChanges.Contains).ToList();
  198. return Task.FromResult(new PullAnalysisResult(conflictingFiles));
  199. }
  200. private static async void PullExecutor(Action<string> resolve, Action<Exception> reject)
  201. {
  202. try
  203. {
  204. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  205. using var repo = new Repository(projectRoot);
  206. // 1. Analyze for conflicts first.
  207. var analysisResult = await AnalyzePullConflicts(repo);
  208. if (analysisResult.HasConflicts)
  209. {
  210. var conflictFiles = string.Join("\n - ", analysisResult.ConflictingFiles);
  211. throw new Exception($"Potential conflicts detected in the following files. Please reset or commit your local changes first:\n - {conflictFiles}");
  212. }
  213. // 2. If analysis passes, proceed with the pull.
  214. var remote = repo.Network.Remotes["origin"];
  215. if (remote == null) throw new Exception("No remote named 'origin' was found.");
  216. var signature = new Signature("Better Git Tool", "bettergit@example.com", DateTimeOffset.Now);
  217. var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (certificate, valid, host) => true } };
  218. var mergeResult = Commands.Pull(repo, signature, pullOptions);
  219. resolve(mergeResult.Status == MergeStatus.UpToDate
  220. ? "Already up-to-date."
  221. : $"Pull successful. Status: {mergeResult.Status}");
  222. }
  223. catch (Exception ex)
  224. {
  225. reject(ex);
  226. }
  227. }
  228. private static void SafePullExecutor(Action<string> resolve, Action<Exception> reject)
  229. {
  230. try
  231. {
  232. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  233. using var repo = new Repository(projectRoot);
  234. var signature = new Signature("Better Git Tool", "bettergit@example.com", DateTimeOffset.Now);
  235. var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (certificate, valid, host) => true } };
  236. var mergeResult = Commands.Pull(repo, signature, pullOptions);
  237. resolve(mergeResult.Status == MergeStatus.UpToDate ? "Already up-to-date." : $"Pull successful. Status: {mergeResult.Status}");
  238. }
  239. catch (Exception ex)
  240. {
  241. reject(ex);
  242. }
  243. }
  244. private static async void ForcePullExecutor(Action<string> resolve, Action<Exception> reject)
  245. {
  246. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  247. var hasLocalChanges = false;
  248. var log = new StringBuilder();
  249. var hasStashed = false;
  250. try
  251. {
  252. using (var repo = new Repository(projectRoot))
  253. {
  254. if (repo.RetrieveStatus().IsDirty) hasLocalChanges = true;
  255. }
  256. log.AppendLine($"Step 0: Has local changes? {hasLocalChanges}");
  257. if (hasLocalChanges)
  258. {
  259. await ExecuteGitCommandAsync("stash push -u -m \"BetterGit-WIP-Pull\"", projectRoot, log, 0, 141);
  260. hasStashed = true;
  261. }
  262. // Use the simpler, more robust pull command.
  263. await ExecuteGitCommandAsync("pull --no-rebase", projectRoot, log, 0, 1, 141);
  264. if (hasStashed)
  265. {
  266. await ExecuteGitCommandAsync("stash pop", projectRoot, log, 0, 1, 141);
  267. hasStashed = false;
  268. }
  269. resolve(log.ToString());
  270. }
  271. catch (Exception ex)
  272. {
  273. if (hasStashed)
  274. {
  275. try
  276. {
  277. await ExecuteGitCommandAsync("stash pop", projectRoot, log, 0, 1, 141);
  278. }
  279. catch (Exception exception)
  280. {
  281. log.AppendLine($"Fatal Error: {exception.Message}");
  282. }
  283. }
  284. log.AppendLine("\n--- FAILED ---");
  285. log.AppendLine(ex.ToString());
  286. reject(new Exception(log.ToString()));
  287. }
  288. }
  289. /// <summary>
  290. /// A reusable helper method to execute Git commands with detailed logging and exit code validation.
  291. /// </summary>
  292. private static async Task ExecuteGitCommandAsync(string args, string workingDir, StringBuilder log, params int[] acceptableExitCodes)
  293. {
  294. var stdOutBuffer = new StringBuilder();
  295. var stdErrBuffer = new StringBuilder();
  296. log?.AppendLine($"\n--- Executing: git {args} ---");
  297. var command = Cli.Wrap(FindGitExecutable())
  298. .WithArguments(args)
  299. .WithWorkingDirectory(workingDir)
  300. .WithValidation(CommandResultValidation.None) // We handle validation manually
  301. | (PipeTarget.ToDelegate(x => stdOutBuffer.Append(x)), PipeTarget.ToDelegate(x => stdErrBuffer.Append(x)));
  302. var result = await command.ExecuteAsync();
  303. log?.AppendLine($"Exit Code: {result.ExitCode}");
  304. if(stdOutBuffer.Length > 0) log?.AppendLine($"StdOut: {stdOutBuffer}");
  305. if(stdErrBuffer.Length > 0) log?.AppendLine($"StdErr: {stdErrBuffer}");
  306. // If the exit code is not in our list of acceptable codes, it's an error.
  307. if (!acceptableExitCodes.Contains(result.ExitCode))
  308. {
  309. throw new Exception($"Command 'git {args}' failed with unexpected exit code {result.ExitCode}.");
  310. }
  311. }
  312. /// <summary>
  313. /// The private logic for the CommitAndPush promise.
  314. /// This code is executed on a background thread.
  315. /// </summary>
  316. private static async void CommitAndPushExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changesToCommit, string commitMessage, string username, string email)
  317. {
  318. try
  319. {
  320. if (string.IsNullOrWhiteSpace(email))
  321. {
  322. throw new Exception("Author email is missing. Please set your email address in Project Settings > Better Git.");
  323. }
  324. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  325. if (projectRoot == null)
  326. {
  327. throw new Exception("Could not find project root.");
  328. }
  329. using (var repo = new Repository(projectRoot))
  330. {
  331. var remote = repo.Network.Remotes["origin"];
  332. if (remote == null) throw new Exception("No remote named 'origin' found.");
  333. var fetchOptions = new FetchOptions { CertificateCheck = (_, _, _) => true };
  334. Commands.Fetch(repo, remote.Name, Array.Empty<string>(), fetchOptions, "Arbitrator pre-push fetch");
  335. var trackingDetails = repo.Head.TrackingDetails;
  336. if (trackingDetails.BehindBy > 0)
  337. {
  338. throw new Exception($"Push aborted. There are {trackingDetails.BehindBy.Value} incoming changes on the remote. Please pull first.");
  339. }
  340. var pathsToStage = new List<string>();
  341. foreach (var change in changesToCommit)
  342. {
  343. if (change.Status == ChangeKind.Deleted) Commands.Remove(repo, change.FilePath);
  344. else pathsToStage.Add(change.FilePath);
  345. }
  346. if (pathsToStage.Any()) Commands.Stage(repo, pathsToStage);
  347. var status = repo.RetrieveStatus();
  348. if (!status.IsDirty) throw new Exception("No effective changes were staged to commit.");
  349. var author = new Signature(username, email, DateTimeOffset.Now);
  350. repo.Commit(commitMessage, author, author);
  351. }
  352. var gitExecutable = FindGitExecutable();
  353. var result = await Cli.Wrap(gitExecutable)
  354. .WithArguments("push")
  355. .WithWorkingDirectory(projectRoot)
  356. .WithValidation(CommandResultValidation.None) // Don't throw exceptions automatically
  357. .ExecuteAsync();
  358. // Exit code 141 means the command was successful but was terminated by a SIGPIPE signal. This is safe to ignore on macOS/Linux.
  359. if (result.ExitCode != 0 && result.ExitCode != 141)
  360. {
  361. throw new Exception($"Push Failed. Error code: {result.ExitCode}");
  362. }
  363. resolve("Successfully committed and pushed changes!");
  364. }
  365. catch (Exception ex)
  366. {
  367. Debug.LogException(ex);
  368. var errorMessage = ex.InnerException?.Message ?? ex.Message;
  369. reject(new Exception(errorMessage));
  370. }
  371. }
  372. /// <summary>
  373. /// The private logic for resetting a file's changes.
  374. /// This is executed on a background thread.
  375. /// </summary>
  376. private static void ResetFileExecutor(Action<string> resolve, Action<Exception> reject, GitChange changeToReset)
  377. {
  378. try
  379. {
  380. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  381. using var repo = new Repository(projectRoot);
  382. // For 'Added' files, checking out doesn't remove them. We need to unstage and delete.
  383. if (changeToReset.Status == ChangeKind.Added)
  384. {
  385. // Unstage the file from the index.
  386. Commands.Unstage(repo, changeToReset.FilePath);
  387. // And delete it from the working directory.
  388. if (projectRoot != null)
  389. {
  390. var fullPath = Path.Combine(projectRoot, changeToReset.FilePath);
  391. if (File.Exists(fullPath))
  392. {
  393. File.Delete(fullPath);
  394. }
  395. }
  396. }
  397. else if (changeToReset.Status == ChangeKind.Renamed)
  398. {
  399. // 1. Unstage the new path
  400. Commands.Unstage(repo, changeToReset.FilePath);
  401. // 2. Delete the new file from the working directory
  402. if (projectRoot != null)
  403. {
  404. var newFullPath = Path.Combine(projectRoot, changeToReset.FilePath);
  405. if (File.Exists(newFullPath)) File.Delete(newFullPath);
  406. }
  407. // 3. Checkout the old path from the HEAD to restore it
  408. repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.OldFilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  409. }
  410. else
  411. {
  412. // For Modified or Deleted files, CheckoutPaths is the correct command to revert them.
  413. repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.FilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  414. }
  415. resolve($"Successfully reset changes for '{changeToReset.FilePath}'");
  416. }
  417. catch (Exception ex)
  418. {
  419. reject(ex);
  420. }
  421. }
  422. private static async void LaunchExternalDiffExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
  423. {
  424. string fileAPath = null; // Before
  425. string fileBPath = null; // After
  426. try
  427. {
  428. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  429. if (projectRoot == null)
  430. {
  431. reject(new Exception("Could not find project root."));
  432. return;
  433. }
  434. using var repo = new Repository(projectRoot);
  435. // Get the content of a file from a specific commit.
  436. string GetFileContentFromHead(string path)
  437. {
  438. var blob = repo.Head.Tip[path]?.Target as Blob;
  439. return blob?.GetContentText() ?? "";
  440. }
  441. // Create a temporary file with the correct extension for syntax highlighting.
  442. string CreateTempFile(string originalPath, string content)
  443. {
  444. var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(originalPath));
  445. File.WriteAllText(tempPath, content);
  446. return tempPath;
  447. }
  448. switch (change.Status)
  449. {
  450. case ChangeKind.Added:
  451. fileAPath = CreateTempFile(change.FilePath, ""); // Empty "before" file
  452. fileBPath = Path.Combine(projectRoot, change.FilePath);
  453. break;
  454. case ChangeKind.Deleted:
  455. fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
  456. fileBPath = CreateTempFile(change.FilePath, ""); // Empty "after" file
  457. break;
  458. case ChangeKind.Renamed:
  459. fileAPath = CreateTempFile(change.OldFilePath, GetFileContentFromHead(change.OldFilePath));
  460. fileBPath = Path.Combine(projectRoot, change.FilePath);
  461. break;
  462. case ChangeKind.Unmodified:
  463. case ChangeKind.Modified:
  464. case ChangeKind.Copied:
  465. case ChangeKind.Ignored:
  466. case ChangeKind.Untracked:
  467. case ChangeKind.TypeChanged:
  468. case ChangeKind.Unreadable:
  469. case ChangeKind.Conflicted:
  470. default: // Modified
  471. fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
  472. fileBPath = Path.Combine(projectRoot, change.FilePath);
  473. break;
  474. }
  475. var vsCodeExecutable = FindVsCodeExecutable();
  476. await Cli.Wrap(vsCodeExecutable)
  477. .WithArguments(args => args
  478. .Add("--diff")
  479. .Add(fileAPath)
  480. .Add(fileBPath))
  481. .ExecuteAsync();
  482. resolve("Launched external diff tool.");
  483. }
  484. catch(Win32Exception ex)
  485. {
  486. // This specific exception is thrown if the executable is not found.
  487. reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
  488. }
  489. catch(Exception ex)
  490. {
  491. reject(ex);
  492. }
  493. finally
  494. {
  495. // We only delete files we created (i.e., not the actual project file).
  496. try
  497. {
  498. if (fileAPath != null && fileAPath.Contains(Path.GetTempPath()) && File.Exists(fileAPath))
  499. {
  500. File.Delete(fileAPath);
  501. }
  502. if (fileBPath != null && fileBPath.Contains(Path.GetTempPath()) && File.Exists(fileBPath))
  503. {
  504. File.Delete(fileBPath);
  505. }
  506. }
  507. catch(Exception cleanupEx)
  508. {
  509. // Log cleanup error but don't throw, as the primary exception (if any) is more important.
  510. Debug.LogError($"Failed to clean up temporary diff files: {cleanupEx.Message}");
  511. }
  512. }
  513. }
  514. /// <summary>
  515. /// The private logic for launching a merge tool for a conflicted file.
  516. /// </summary>
  517. private static async void LaunchMergeToolExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
  518. {
  519. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  520. if (projectRoot == null)
  521. {
  522. reject(new Exception("Could not find project root."));
  523. return;
  524. }
  525. if (change.FilePath == null)
  526. {
  527. reject(new Exception("Could not find file path."));
  528. return;
  529. }
  530. try
  531. {
  532. var fileExtension = Path.GetExtension(change.FilePath)?.ToLower();
  533. if (fileExtension is ".prefab" or ".unity")
  534. {
  535. reject(new Exception("Cannot resolve conflicts for binary files (.prefab, .unity) with VS Code. Please use an external merge tool."));
  536. return;
  537. }
  538. // Launch VS Code and wait for it to close.
  539. var vsCodeExecutable = FindVsCodeExecutable();
  540. await Cli.Wrap(vsCodeExecutable)
  541. .WithArguments(args => args
  542. .Add("--wait") // Crucial flag
  543. .Add(change.FilePath))
  544. .WithWorkingDirectory(projectRoot)
  545. .ExecuteAsync();
  546. var fullPath = Path.Combine(projectRoot, change.FilePath);
  547. var fileContent = await File.ReadAllTextAsync(fullPath);
  548. if (fileContent.Contains("<<<<<<<") || fileContent.Contains("=======") || fileContent.Contains(">>>>>>>"))
  549. {
  550. // The user closed the editor but did not resolve the conflict.
  551. resolve($"Conflict in '{change.FilePath}' was not resolved. Please try again.");
  552. return;
  553. }
  554. var gitExecutable = FindGitExecutable();
  555. // The markers are gone, so now we can tell Git the conflict is resolved.
  556. await Cli.Wrap(gitExecutable)
  557. .WithArguments($"add \"{change.FilePath}\"")
  558. .WithWorkingDirectory(projectRoot)
  559. .ExecuteAsync();
  560. // The conflict is resolved and staged. Now, unstage the file to give the user control.
  561. await Cli.Wrap(gitExecutable)
  562. .WithArguments($"reset HEAD \"{change.FilePath}\"")
  563. .WithWorkingDirectory(projectRoot)
  564. .ExecuteAsync();
  565. resolve($"Successfully resolved conflict in '{change.FilePath}'. The file is now modified and ready for review.");
  566. }
  567. catch (Win32Exception ex)
  568. {
  569. reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
  570. }
  571. catch (Exception ex)
  572. {
  573. reject(ex);
  574. }
  575. }
  576. /// <summary>
  577. /// Executor for UnstageAllFilesIfSafe. Checks for conflicts and staged files, then unstages if appropriate.
  578. /// </summary>
  579. private static async void UnstageAllFilesIfSafeExecutor(Action<bool> resolve, Action<Exception> reject)
  580. {
  581. try
  582. {
  583. var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
  584. if (projectRoot == null)
  585. {
  586. reject(new Exception("Could not find project root."));
  587. return;
  588. }
  589. using var repo = new Repository(projectRoot);
  590. // Safety Check: Do not proceed if there are any conflicts.
  591. if (repo.Index.Conflicts.Any())
  592. {
  593. resolve(false);
  594. return;
  595. }
  596. // Check if there are any files staged for commit.
  597. var stagedFiles = repo.RetrieveStatus().Where(s => s.State is
  598. FileStatus.NewInIndex or
  599. FileStatus.ModifiedInIndex or
  600. FileStatus.DeletedFromIndex or
  601. FileStatus.RenamedInIndex or
  602. FileStatus.TypeChangeInIndex);
  603. if (!stagedFiles.Any())
  604. {
  605. resolve(false); // Nothing to do.
  606. return;
  607. }
  608. // If we get here, it's safe to unstage everything.
  609. await ExecuteGitCommandAsync("reset", projectRoot, null, 0, 141);
  610. resolve(true); // Signal that files were unstaged.
  611. }
  612. catch (Exception ex)
  613. {
  614. reject(ex);
  615. }
  616. }
  617. /// <summary>
  618. /// Searches for an executable in common macOS PATH directories.
  619. /// </summary>
  620. private static string FindExecutable(string name)
  621. {
  622. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
  623. {
  624. // CliWrap handles PATH search on Windows automatically.
  625. return name;
  626. }
  627. // For macOS/Linux, we need to be more explicit due to Unity's sandboxing.
  628. string[] searchPaths = { "/usr/local/bin", "/usr/bin", "/bin", "/opt/homebrew/bin" };
  629. foreach (var path in searchPaths)
  630. {
  631. var fullPath = Path.Combine(path, name);
  632. if (File.Exists(fullPath))
  633. {
  634. return fullPath;
  635. }
  636. }
  637. throw new FileNotFoundException($"Could not find executable '{name}'. Please ensure it is installed and in your system's PATH.");
  638. }
  639. private static string FindVsCodeExecutable() => FindExecutable("code");
  640. private static string FindGitExecutable() => FindExecutable("git");
  641. }
  642. }