GitExecutors.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  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 System.IO;
  8. using System.Linq;
  9. using UnityEngine;
  10. using System.Text;
  11. using LibGit2Sharp;
  12. using System.Globalization;
  13. using System.ComponentModel;
  14. using UnityEngine.Scripting;
  15. using System.Threading.Tasks;
  16. using Terra.Arbitrator.Settings;
  17. using System.Collections.Generic;
  18. using System.Text.RegularExpressions;
  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 ??= MainThreadDataCache.ProjectRoot;
  39. private static string GetAuthenticatedRemoteUrl()
  40. {
  41. var authUsername = BetterGitSettings.AuthUsername;
  42. var authPassword = BetterGitSettings.AuthPassword;
  43. if (string.IsNullOrEmpty(authUsername) || string.IsNullOrEmpty(authPassword))
  44. {
  45. return "origin";
  46. }
  47. using var repo = new Repository(ProjectRoot);
  48. var remote = repo.Network.Remotes["origin"];
  49. if (remote == null) throw new Exception("No remote named 'origin' found.");
  50. var originalUrl = remote.Url;
  51. var authenticatedUrl = Regex.Replace(originalUrl,
  52. @"://",
  53. $"://{Uri.EscapeDataString(authUsername)}:{Uri.EscapeDataString(authPassword)}@");
  54. return authenticatedUrl;
  55. }
  56. public static void GetBranchDataExecutor(Action<BranchData> resolve, Action<Exception> reject)
  57. {
  58. try
  59. {
  60. using var repo = new Repository(ProjectRoot);
  61. var data = new BranchData
  62. {
  63. CurrentBranch = repo.Head.FriendlyName,
  64. AllBranches = repo.Branches
  65. .Where(b => !b.FriendlyName.Contains("HEAD"))
  66. .Select(b => b.FriendlyName.Replace("origin/", ""))
  67. .Distinct()
  68. .OrderBy(name => name)
  69. .ToList()
  70. };
  71. resolve(data);
  72. }
  73. catch(Exception ex)
  74. {
  75. reject(ex);
  76. }
  77. }
  78. public static async void SwitchBranchExecutor(Action<string> resolve, Action<Exception> reject, string branchName)
  79. {
  80. try
  81. {
  82. var log = new StringBuilder();
  83. await GitCommand.RunGitAsync(log, new[] { "checkout", branchName });
  84. resolve($"Successfully switched to branch '{branchName}'.");
  85. }
  86. catch (Exception ex)
  87. {
  88. reject(ex);
  89. }
  90. }
  91. public static async void ResetAndSwitchBranchExecutor(Action<string> resolve, Action<Exception> reject, string branchName)
  92. {
  93. try
  94. {
  95. var log = new StringBuilder();
  96. await GitCommand.RunGitAsync(log, new[] { "reset", "--hard", "HEAD" });
  97. await GitCommand.RunGitAsync(log, new[] { "clean", "-fd" });
  98. await GitCommand.RunGitAsync(log, new[] { "checkout", branchName });
  99. resolve($"Discarded local changes and switched to branch '{branchName}'.");
  100. }
  101. catch (Exception ex)
  102. {
  103. reject(ex);
  104. }
  105. }
  106. /// <summary>
  107. /// Synchronous helper to get a GitChange object for a single file.
  108. /// This is public so it can be called by the GitService wrapper.
  109. /// </summary>
  110. public static GitChange GetChangeForFile(string filePath)
  111. {
  112. try
  113. {
  114. using var repo = new Repository(ProjectRoot);
  115. if (repo.Index.Conflicts.Any(c => c.Ours.Path == filePath))
  116. {
  117. return new GitChange(filePath, null, ChangeKind.Conflicted);
  118. }
  119. var statusEntry = repo.RetrieveStatus(filePath);
  120. return statusEntry switch
  121. {
  122. FileStatus.NewInWorkdir or FileStatus.NewInIndex => new GitChange(filePath, null, ChangeKind.Added),
  123. FileStatus.ModifiedInWorkdir or FileStatus.ModifiedInIndex => new GitChange(filePath, null, ChangeKind.Modified),
  124. FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => new GitChange(filePath, null, ChangeKind.Deleted),
  125. FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex =>
  126. new GitChange(filePath, null, ChangeKind.Renamed),
  127. _ => null
  128. };
  129. }
  130. catch { return null; } // Suppress errors if repo is in a unique state
  131. }
  132. // --- Promise Executor Implementations ---
  133. public static async void GetUpstreamAheadByExecutor(Action<int?> resolve, Action<Exception> reject, Action<float, string> onProgress)
  134. {
  135. try
  136. {
  137. string refSpec;
  138. using (var tempRepo = new Repository(ProjectRoot))
  139. {
  140. var currentBranch = tempRepo.Head;
  141. if (currentBranch.TrackedBranch == null)
  142. {
  143. resolve(0);
  144. return;
  145. }
  146. var branchName = currentBranch.FriendlyName;
  147. var remoteName = currentBranch.TrackedBranch.RemoteName;
  148. refSpec = $"{branchName}:refs/remotes/{remoteName}/{branchName}";
  149. }
  150. var authenticatedUrl = GetAuthenticatedRemoteUrl();
  151. var progressReporter = new Progress<string>(line => ParseProgress(line, onProgress));
  152. await GitCommand.RunGitAsync(new StringBuilder(), new[] { "fetch", authenticatedUrl, refSpec, "--progress" }, progressReporter);
  153. using var repo = new Repository(ProjectRoot);
  154. resolve(repo.Head.TrackingDetails.BehindBy);
  155. }
  156. catch (Exception ex)
  157. {
  158. if (ex.Message.Contains("is not tracking a remote branch"))
  159. {
  160. resolve(null);
  161. }
  162. else
  163. {
  164. reject(ex);
  165. }
  166. }
  167. }
  168. public static void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
  169. {
  170. try
  171. {
  172. var changes = new List<GitChange>();
  173. using var repo = new Repository(ProjectRoot);
  174. var conflictedPaths = new HashSet<string>(repo.Index.Conflicts.Select(c => c.Ours.Path));
  175. var statusOptions = new StatusOptions
  176. {
  177. IncludeUntracked = true,
  178. RecurseUntrackedDirs = true,
  179. DetectRenamesInIndex = true,
  180. DetectRenamesInWorkDir = true
  181. };
  182. foreach (var entry in repo.RetrieveStatus(statusOptions))
  183. {
  184. if (conflictedPaths.Contains(entry.FilePath))
  185. {
  186. if (changes.All(c => c.FilePath != entry.FilePath))
  187. {
  188. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Conflicted));
  189. }
  190. continue;
  191. }
  192. switch(entry.State)
  193. {
  194. case FileStatus.NewInWorkdir:
  195. case FileStatus.NewInIndex:
  196. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Added));
  197. break;
  198. case FileStatus.ModifiedInWorkdir:
  199. case FileStatus.ModifiedInIndex:
  200. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Modified));
  201. break;
  202. case FileStatus.DeletedFromWorkdir:
  203. case FileStatus.DeletedFromIndex:
  204. changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Deleted));
  205. break;
  206. case FileStatus.RenamedInWorkdir:
  207. case FileStatus.RenamedInIndex:
  208. var renameDetails = entry.HeadToIndexRenameDetails ?? entry.IndexToWorkDirRenameDetails;
  209. changes.Add(renameDetails != null ? new GitChange(renameDetails.NewFilePath, renameDetails.OldFilePath, ChangeKind.Renamed)
  210. : new GitChange(entry.FilePath, "Unknown", ChangeKind.Renamed));
  211. break;
  212. }
  213. }
  214. resolve(changes);
  215. }
  216. catch (Exception ex)
  217. {
  218. reject(ex);
  219. }
  220. }
  221. public static async void CommitAndPushExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changesToCommit, string commitMessage, string username, string email, Action<float, string> onProgress)
  222. {
  223. try
  224. {
  225. if (string.IsNullOrWhiteSpace(email))
  226. {
  227. throw new Exception("Author email is missing. Please set your email address in Project Settings > Better Git.");
  228. }
  229. var authenticatedUrl = GetAuthenticatedRemoteUrl();
  230. await GitCommand.RunGitAsync(new StringBuilder(), new[] { "fetch", authenticatedUrl });
  231. using (var repo = new Repository(ProjectRoot))
  232. {
  233. var remote = repo.Network.Remotes["origin"];
  234. if (remote == null) throw new Exception("No remote named 'origin' found.");
  235. var trackingDetails = repo.Head.TrackingDetails;
  236. if (trackingDetails.BehindBy > 0)
  237. {
  238. throw new Exception($"Push aborted. There are {trackingDetails.BehindBy.Value} incoming changes on the remote. Please pull first.");
  239. }
  240. var pathsToStage = new List<string>();
  241. foreach (var change in changesToCommit)
  242. {
  243. switch (change.Status)
  244. {
  245. case ChangeKind.Deleted:
  246. Commands.Remove(repo, change.FilePath);
  247. break;
  248. case ChangeKind.Renamed:
  249. Commands.Remove(repo, change.OldFilePath);
  250. pathsToStage.Add(change.FilePath);
  251. break;
  252. default:
  253. pathsToStage.Add(change.FilePath);
  254. break;
  255. }
  256. }
  257. if (pathsToStage.Any()) Commands.Stage(repo, pathsToStage);
  258. var status = repo.RetrieveStatus();
  259. if (!status.IsDirty) throw new Exception("No effective changes were staged to commit.");
  260. var author = new Signature(username, email, DateTimeOffset.Now);
  261. repo.Commit(commitMessage, author, author);
  262. }
  263. var progressReporter = new Progress<string>(line => ParseProgress(line, onProgress));
  264. using var tempRepo = new Repository(ProjectRoot);
  265. var currentBranch = tempRepo.Head.FriendlyName;
  266. await GitCommand.RunGitAsync(new StringBuilder(), new[] { "push", "--progress", authenticatedUrl, $"HEAD:{currentBranch}" }, progressReporter, 0, 141);
  267. resolve("Successfully committed and pushed changes!");
  268. }
  269. catch (Exception ex)
  270. {
  271. var errorMessage = ex.InnerException?.Message ?? ex.Message;
  272. reject(new Exception(errorMessage));
  273. }
  274. }
  275. private static void ParseProgress(string line, Action<float, string> onProgress)
  276. {
  277. if (onProgress == null || string.IsNullOrWhiteSpace(line)) return;
  278. line = line.Trim();
  279. var parts = line.Split(new[] { ':' }, 2);
  280. if (parts.Length < 2) return;
  281. var action = parts[0];
  282. var progressPart = parts[1];
  283. var percentIndex = progressPart.IndexOf('%');
  284. if (percentIndex == -1) return;
  285. var percentString = progressPart[..percentIndex].Trim();
  286. if (!float.TryParse(percentString, NumberStyles.Any, CultureInfo.InvariantCulture, out var percentage)) return;
  287. var progressValue = percentage / 100.0f;
  288. onProgress(progressValue, $"{action}...");
  289. }
  290. public static void ResetFileExecutor(Action<string> resolve, Action<Exception> reject, GitChange changeToReset)
  291. {
  292. try
  293. {
  294. using var repo = new Repository(ProjectRoot);
  295. switch (changeToReset.Status)
  296. {
  297. case ChangeKind.Added:
  298. {
  299. Commands.Unstage(repo, changeToReset.FilePath);
  300. var fullPath = Path.Combine(ProjectRoot, changeToReset.FilePath);
  301. if (File.Exists(fullPath))
  302. {
  303. File.Delete(fullPath);
  304. }
  305. break;
  306. }
  307. case ChangeKind.Renamed:
  308. {
  309. Commands.Unstage(repo, changeToReset.FilePath);
  310. var newFullPath = Path.Combine(ProjectRoot, changeToReset.FilePath);
  311. if (File.Exists(newFullPath)) File.Delete(newFullPath);
  312. repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.OldFilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  313. break;
  314. }
  315. default:
  316. repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.FilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  317. break;
  318. }
  319. resolve($"Successfully reset changes for '{changeToReset.FilePath}'");
  320. }
  321. catch (Exception ex)
  322. {
  323. reject(ex);
  324. }
  325. }
  326. public static async void LaunchExternalDiffExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
  327. {
  328. string fileAPath = null; // Before
  329. string fileBPath = null; // After
  330. try
  331. {
  332. using var repo = new Repository(ProjectRoot);
  333. string GetFileContentFromHead(string path)
  334. {
  335. var blob = repo.Head.Tip[path]?.Target as Blob;
  336. return blob?.GetContentText() ?? "";
  337. }
  338. string CreateTempFile(string originalPath, string content)
  339. {
  340. var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(originalPath));
  341. File.WriteAllText(tempPath, content);
  342. return tempPath;
  343. }
  344. switch (change.Status)
  345. {
  346. case ChangeKind.Added:
  347. fileAPath = CreateTempFile(change.FilePath, "");
  348. fileBPath = Path.Combine(ProjectRoot, change.FilePath);
  349. break;
  350. case ChangeKind.Deleted:
  351. fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
  352. fileBPath = CreateTempFile(change.FilePath, "");
  353. break;
  354. case ChangeKind.Renamed:
  355. fileAPath = CreateTempFile(change.OldFilePath, GetFileContentFromHead(change.OldFilePath));
  356. fileBPath = Path.Combine(ProjectRoot, change.FilePath);
  357. break;
  358. default: // Modified
  359. fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
  360. fileBPath = Path.Combine(ProjectRoot, change.FilePath);
  361. break;
  362. }
  363. await GitCommand.RunVsCodeAsync(new StringBuilder(), new[] { "--diff", fileAPath, fileBPath });
  364. resolve("Launched external diff tool.");
  365. }
  366. catch(Win32Exception ex)
  367. {
  368. reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
  369. }
  370. catch(Exception ex)
  371. {
  372. reject(ex);
  373. }
  374. finally
  375. {
  376. try
  377. {
  378. if (fileAPath != null && fileAPath.Contains(Path.GetTempPath()) && File.Exists(fileAPath)) File.Delete(fileAPath);
  379. if (fileBPath != null && fileBPath.Contains(Path.GetTempPath()) && File.Exists(fileBPath)) File.Delete(fileBPath);
  380. }
  381. catch(Exception cleanupEx)
  382. {
  383. Debug.LogError($"Failed to clean up temporary diff files: {cleanupEx.Message}");
  384. }
  385. }
  386. }
  387. public static void FileLevelConflictCheckExecutor(Action<PullAnalysisResult> resolve, Action<Exception> reject)
  388. {
  389. try
  390. {
  391. using var repo = new Repository(ProjectRoot);
  392. var result = AnalyzePullConflictsInternal(repo).Result;
  393. resolve(result);
  394. }
  395. catch (Exception ex)
  396. {
  397. reject(ex);
  398. }
  399. }
  400. private static Task<PullAnalysisResult> AnalyzePullConflictsInternal(Repository repo)
  401. {
  402. var remote = repo.Network.Remotes["origin"];
  403. if (remote == null) throw new Exception("No remote named 'origin' was found.");
  404. var localBranch = repo.Head;
  405. var remoteBranch = repo.Head.TrackedBranch;
  406. if (remoteBranch == null) throw new Exception("Current branch is not tracking a remote branch.");
  407. var mergeBase = repo.ObjectDatabase.FindMergeBase(localBranch.Tip, remoteBranch.Tip);
  408. if (mergeBase == null) throw new Exception("Could not find a common ancestor.");
  409. var theirChanges = new HashSet<string>(repo.Diff.Compare<TreeChanges>(mergeBase.Tree, remoteBranch.Tip.Tree).Select(c => c.Path));
  410. var ourChanges = new HashSet<string>(repo.Diff.Compare<TreeChanges>(mergeBase.Tree, localBranch.Tip.Tree).Select(c => c.Path));
  411. foreach (var statusEntry in repo.RetrieveStatus()) ourChanges.Add(statusEntry.FilePath);
  412. return Task.FromResult(new PullAnalysisResult(ourChanges.Where(theirChanges.Contains).ToList()));
  413. }
  414. public static async void SafePullExecutor(Action<string> resolve, Action<Exception> reject)
  415. {
  416. try
  417. {
  418. var authenticatedUrl = GetAuthenticatedRemoteUrl();
  419. var log = new StringBuilder();
  420. string currentBranchName;
  421. using (var repo = new Repository(ProjectRoot))
  422. {
  423. currentBranchName = repo.Head.FriendlyName;
  424. }
  425. await GitCommand.RunGitAsync(log, new[] { "pull", "--no-rebase", authenticatedUrl, currentBranchName }, 0, 141);
  426. resolve(log.ToString());
  427. }
  428. catch (Exception ex)
  429. {
  430. reject(ex);
  431. }
  432. }
  433. public static async void ForcePullExecutor(Action<string> resolve, Action<Exception> reject)
  434. {
  435. var log = new StringBuilder();
  436. var hasStashed = false;
  437. try
  438. {
  439. using (var repo = new Repository(ProjectRoot))
  440. {
  441. if (repo.RetrieveStatus().IsDirty)
  442. {
  443. await GitCommand.RunGitAsync(log, new[] { "stash", "push", "-u", "-m", "BetterGit-WIP-Pull" }, 0, 141);
  444. hasStashed = true;
  445. }
  446. }
  447. await GitCommand.RunGitAsync(log, new[] { "pull", "--no-rebase" }, 0, 1, 141);
  448. if (hasStashed)
  449. {
  450. await GitCommand.RunGitAsync(log, new[] { "stash", "pop" }, 0, 1, 141);
  451. await GitCommand.RunGitAsync(log, new[] { "stash", "drop" }, 0, 141);
  452. }
  453. resolve(log.ToString());
  454. }
  455. catch (Exception ex)
  456. {
  457. if (hasStashed)
  458. {
  459. try
  460. {
  461. await GitCommand.RunGitAsync(new StringBuilder(), new[] { "stash", "pop" }, 0, 1, 141);
  462. }
  463. catch (Exception exception)
  464. {
  465. log.AppendLine($"Fatal Error trying to pop stash after a failed pull: {exception.Message}");
  466. }
  467. }
  468. log.AppendLine("\n--- PULL FAILED ---");
  469. log.AppendLine(ex.ToString());
  470. reject(new Exception(log.ToString()));
  471. }
  472. }
  473. public static async void PullAndOverwriteExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> resolutions)
  474. {
  475. var tempFiles = new Dictionary<string, string>();
  476. var log = new StringBuilder();
  477. try
  478. {
  479. foreach (var resolution in resolutions.Where(r => r.Resolution == GitChange.ConflictResolution.Mine))
  480. {
  481. var fullPath = Path.Combine(ProjectRoot, resolution.FilePath);
  482. var tempPath = Path.GetTempFileName();
  483. File.Copy(fullPath, tempPath, true);
  484. tempFiles[resolution.FilePath] = tempPath;
  485. }
  486. using (var repo = new Repository(ProjectRoot))
  487. {
  488. var filesToReset = resolutions.Where(r => r.Resolution != GitChange.ConflictResolution.None).Select(r => r.FilePath).ToArray();
  489. if(filesToReset.Length > 0)
  490. {
  491. repo.CheckoutPaths(repo.Head.Tip.Sha, filesToReset, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
  492. }
  493. }
  494. await GitCommand.RunGitAsync(log, new[] { "pull", "--no-rebase" });
  495. foreach (var entry in tempFiles)
  496. {
  497. var finalPath = Path.Combine(ProjectRoot, entry.Key);
  498. File.Copy(entry.Value, finalPath, true);
  499. await GitCommand.RunGitAsync(log, new[] { "add", entry.Key });
  500. }
  501. var unresolvedFiles = resolutions.Where(r => r.Resolution == GitChange.ConflictResolution.None).ToList();
  502. if (unresolvedFiles.Any())
  503. {
  504. var fileList = string.Join(", ", unresolvedFiles.Select(f => f.FilePath));
  505. resolve($"Pull completed with unresolved conflicts in: {fileList}. Please resolve them manually.");
  506. }
  507. else
  508. {
  509. resolve("Pull successful. Your chosen local changes have been preserved.");
  510. }
  511. }
  512. catch (Exception ex)
  513. {
  514. reject(ex);
  515. }
  516. finally
  517. {
  518. foreach (var tempFile in tempFiles.Values.Where(File.Exists))
  519. {
  520. File.Delete(tempFile);
  521. }
  522. }
  523. }
  524. public static async void LaunchMergeToolExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
  525. {
  526. try
  527. {
  528. if (change.FilePath == null)
  529. {
  530. reject(new Exception("Could not find file path."));
  531. return;
  532. }
  533. var fileExtension = Path.GetExtension(change.FilePath).ToLower();
  534. if (fileExtension is ".prefab" or ".unity")
  535. {
  536. reject(new Exception("Cannot auto-resolve conflicts for binary files. Please use an external merge tool."));
  537. return;
  538. }
  539. await GitCommand.RunVsCodeAsync(new StringBuilder(), new[] { "--wait", change.FilePath }, 0, 141);
  540. var fullPath = Path.Combine(ProjectRoot, change.FilePath);
  541. var fileContent = await File.ReadAllTextAsync(fullPath);
  542. if (fileContent.Contains("<<<<<<<"))
  543. {
  544. resolve($"Conflict in '{change.FilePath}' was not resolved. Please try again.");
  545. return;
  546. }
  547. await GitCommand.RunGitAsync(new StringBuilder(), new[] { "add", change.FilePath });
  548. await GitCommand.RunGitAsync(new StringBuilder(), new[] { "reset", "HEAD", change.FilePath });
  549. resolve($"Successfully resolved conflict in '{change.FilePath}'. The file is now modified and ready for review.");
  550. }
  551. catch (Win32Exception ex)
  552. {
  553. reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
  554. }
  555. catch (Exception ex)
  556. {
  557. reject(ex);
  558. }
  559. }
  560. public static async void UnstageAllFilesIfSafeExecutor(Action<bool> resolve, Action<Exception> reject)
  561. {
  562. try
  563. {
  564. using var repo = new Repository(ProjectRoot);
  565. if (repo.Index.Conflicts.Any())
  566. {
  567. resolve(false);
  568. return;
  569. }
  570. var stagedFiles = repo.RetrieveStatus().Count(s => s.State is
  571. FileStatus.NewInIndex or
  572. FileStatus.ModifiedInIndex or
  573. FileStatus.DeletedFromIndex or
  574. FileStatus.RenamedInIndex or
  575. FileStatus.TypeChangeInIndex);
  576. if (stagedFiles == 0)
  577. {
  578. resolve(false);
  579. return;
  580. }
  581. await GitCommand.RunGitAsync(new StringBuilder(), new[] { "reset" });
  582. resolve(true);
  583. }
  584. catch (Exception ex)
  585. {
  586. reject(ex);
  587. }
  588. }
  589. }
  590. }