GitService.cs 34 KB

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