|
@@ -4,11 +4,19 @@
|
|
// repository. All public methods are asynchronous and return a promise.
|
|
// repository. All public methods are asynchronous and return a promise.
|
|
|
|
|
|
using System;
|
|
using System;
|
|
|
|
+using CliWrap;
|
|
using System.IO;
|
|
using System.IO;
|
|
-using LibGit2Sharp;
|
|
|
|
|
|
+using System.Linq;
|
|
using UnityEngine;
|
|
using UnityEngine;
|
|
|
|
+using System.Text;
|
|
|
|
+using LibGit2Sharp;
|
|
|
|
+using System.ComponentModel;
|
|
|
|
+using System.Threading.Tasks;
|
|
|
|
+using Terra.Arbitrator.Settings;
|
|
using Terra.Arbitrator.Promises;
|
|
using Terra.Arbitrator.Promises;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Generic;
|
|
|
|
+using System.Runtime.InteropServices;
|
|
|
|
+using Debug = UnityEngine.Debug;
|
|
|
|
|
|
namespace Terra.Arbitrator.Services
|
|
namespace Terra.Arbitrator.Services
|
|
{
|
|
{
|
|
@@ -17,15 +25,15 @@ namespace Terra.Arbitrator.Services
|
|
// Public method that returns the promise
|
|
// Public method that returns the promise
|
|
public static IPromise<List<GitChange>> CompareLocalToRemote()
|
|
public static IPromise<List<GitChange>> CompareLocalToRemote()
|
|
{
|
|
{
|
|
- return new Promise<List<GitChange>>(CompareExecutor);
|
|
|
|
|
|
+ return new Promise<List<GitChange>>(GetLocalStatusExecutor);
|
|
}
|
|
}
|
|
|
|
|
|
// Public method that returns the promise
|
|
// Public method that returns the promise
|
|
- public static IPromise<string> CommitAndPush(List<GitChange> changesToCommit, string commitMessage)
|
|
|
|
|
|
+ public static IPromise<string> CommitAndPush(List<GitChange> changesToCommit, string commitMessage, string username, string email)
|
|
{
|
|
{
|
|
// Use a lambda here to pass arguments to the executor method
|
|
// Use a lambda here to pass arguments to the executor method
|
|
return new Promise<string>((resolve, reject) =>
|
|
return new Promise<string>((resolve, reject) =>
|
|
- CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage));
|
|
|
|
|
|
+ CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage, username, email));
|
|
}
|
|
}
|
|
|
|
|
|
// Creates a promise to revert a single file to its HEAD revision.
|
|
// Creates a promise to revert a single file to its HEAD revision.
|
|
@@ -34,51 +42,315 @@ namespace Terra.Arbitrator.Services
|
|
return new Promise<string>((resolve, reject) =>
|
|
return new Promise<string>((resolve, reject) =>
|
|
ResetFileExecutor(resolve, reject, changeToReset));
|
|
ResetFileExecutor(resolve, reject, changeToReset));
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+ // Creates a promise to launch an external diff tool (VS Code).
|
|
|
|
+ public static IPromise<string> LaunchExternalDiff(GitChange change)
|
|
|
|
+ {
|
|
|
|
+ return new Promise<string>((resolve, reject) => LaunchExternalDiffExecutor(resolve, reject, change));
|
|
|
|
+ }
|
|
|
|
|
|
- // Creates a promise to get the diff patch for a single file against HEAD.
|
|
|
|
- public static IPromise<string> GetFileDiff(GitChange change)
|
|
|
|
|
|
+ // Performs a non-destructive analysis of a potential pull.
|
|
|
|
+ public static IPromise<PullAnalysisResult> AnalyzePullConflicts()
|
|
|
|
+ {
|
|
|
|
+ return new Promise<PullAnalysisResult>(FileLevelConflictCheckExecutor);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // Performs a pull, assuming analysis has already confirmed it's safe.
|
|
|
|
+ public static IPromise<string> PerformSafePull()
|
|
{
|
|
{
|
|
- return new Promise<string>((resolve, reject) => GetFileDiffExecutor(resolve, reject, change));
|
|
|
|
|
|
+ return new Promise<string>(SafePullExecutor);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+ // Creates a promise to perform a pull that may result in conflicts.
|
|
|
|
+ public static IPromise<string> ForcePull()
|
|
|
|
+ {
|
|
|
|
+ return new Promise<string>(ForcePullExecutor);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // Creates a promise to launch an external merge tool for a conflicted file.
|
|
|
|
+ public static IPromise<string> LaunchMergeTool(GitChange change)
|
|
|
|
+ {
|
|
|
|
+ return new Promise<string>((resolve, reject) => LaunchMergeToolExecutor(resolve, reject, change));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// Creates a promise to unstage all files if the repository is in a clean, non-conflicted state.
|
|
|
|
+ /// </summary>
|
|
|
|
+ public static IPromise<bool> UnstageAllFilesIfSafe()
|
|
|
|
+ {
|
|
|
|
+ return new Promise<bool>(UnstageAllFilesIfSafeExecutor);
|
|
|
|
+ }
|
|
|
|
+
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// The private logic for the CompareLocalToRemote promise.
|
|
|
|
- /// This code is executed on a background thread by the Promise constructor.
|
|
|
|
|
|
+ /// Synchronous helper to get a GitChange object for a single file.
|
|
|
|
+ /// Needed by the static persistence script which cannot easily use promises.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static void CompareExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
|
|
|
|
|
|
+ public static GitChange GetChangeForFile(string filePath)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ using var repo = new Repository(projectRoot);
|
|
|
|
+ var statusEntry = repo.RetrieveStatus(filePath);
|
|
|
|
+
|
|
|
|
+ // Determine ChangeKind from FileStatus
|
|
|
|
+ return statusEntry switch
|
|
|
|
+ {
|
|
|
|
+ FileStatus.NewInWorkdir or FileStatus.NewInIndex => new GitChange(filePath, null, ChangeKind.Added),
|
|
|
|
+ FileStatus.ModifiedInWorkdir or FileStatus.ModifiedInIndex => new GitChange(filePath, null, ChangeKind.Modified),
|
|
|
|
+ FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => new GitChange(filePath, null, ChangeKind.Deleted),
|
|
|
|
+ FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex =>
|
|
|
|
+ // Getting the old path from a single status entry is complex,
|
|
|
|
+ // for reset purposes, treating it as modified is safe enough.
|
|
|
|
+ new GitChange(filePath, null, ChangeKind.Renamed),
|
|
|
|
+ _ => null
|
|
|
|
+ };
|
|
|
|
+ }
|
|
|
|
+ catch { return null; } // Suppress errors if repo is in a unique state
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// Gets the status of the local repository, showing unstaged changes.
|
|
|
|
+ /// This is the equivalent of 'git status'.
|
|
|
|
+ /// </summary>
|
|
|
|
+ private static void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
|
|
{
|
|
{
|
|
try
|
|
try
|
|
{
|
|
{
|
|
var changes = new List<GitChange>();
|
|
var changes = new List<GitChange>();
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
using var repo = new Repository(projectRoot);
|
|
using var repo = new Repository(projectRoot);
|
|
|
|
+
|
|
|
|
+ var conflictedPaths = new HashSet<string>(repo.Index.Conflicts.Select(c => c.Ours.Path));
|
|
|
|
+
|
|
|
|
+ var statusOptions = new StatusOptions
|
|
|
|
+ {
|
|
|
|
+ IncludeUntracked = true,
|
|
|
|
+ RecurseUntrackedDirs = true,
|
|
|
|
+ DetectRenamesInIndex = true,
|
|
|
|
+ DetectRenamesInWorkDir = true
|
|
|
|
+ };
|
|
|
|
+ foreach (var entry in repo.RetrieveStatus(statusOptions))
|
|
|
|
+ {
|
|
|
|
+ if (conflictedPaths.Contains(entry.FilePath))
|
|
|
|
+ {
|
|
|
|
+ changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Conflicted));
|
|
|
|
+ continue;
|
|
|
|
+ }
|
|
|
|
+ switch(entry.State)
|
|
|
|
+ {
|
|
|
|
+ case FileStatus.NewInWorkdir:
|
|
|
|
+ case FileStatus.NewInIndex:
|
|
|
|
+ changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Added));
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ case FileStatus.ModifiedInWorkdir:
|
|
|
|
+ case FileStatus.ModifiedInIndex:
|
|
|
|
+ changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Modified));
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ case FileStatus.DeletedFromWorkdir:
|
|
|
|
+ case FileStatus.DeletedFromIndex:
|
|
|
|
+ changes.Add(new GitChange(entry.FilePath, null, ChangeKind.Deleted));
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ case FileStatus.RenamedInWorkdir:
|
|
|
|
+ case FileStatus.RenamedInIndex:
|
|
|
|
+ var renameDetails = entry.HeadToIndexRenameDetails ?? entry.IndexToWorkDirRenameDetails;
|
|
|
|
+ changes.Add(renameDetails != null ? new GitChange(renameDetails.NewFilePath, renameDetails.OldFilePath, ChangeKind.Renamed)
|
|
|
|
+ // Fallback for safety, though this path should rarely be hit with correct options.
|
|
|
|
+ : new GitChange(entry.FilePath, "Unknown", ChangeKind.Renamed));
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ case FileStatus.Nonexistent:
|
|
|
|
+ case FileStatus.Unaltered:
|
|
|
|
+ case FileStatus.TypeChangeInIndex:
|
|
|
|
+ case FileStatus.TypeChangeInWorkdir:
|
|
|
|
+ case FileStatus.Unreadable:
|
|
|
|
+ case FileStatus.Ignored:
|
|
|
|
+ case FileStatus.Conflicted:
|
|
|
|
+ default:
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ resolve(changes);
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ Debug.LogException(ex);
|
|
|
|
+ reject(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private static void FileLevelConflictCheckExecutor(Action<PullAnalysisResult> resolve, Action<Exception> reject)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ using var repo = new Repository(projectRoot);
|
|
|
|
+ var result = AnalyzePullConflicts(repo).Result; // .Result is safe here as it's wrapped in a promise
|
|
|
|
+ resolve(result);
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ reject(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private static Task<PullAnalysisResult> AnalyzePullConflicts(Repository repo)
|
|
|
|
+ {
|
|
|
|
+ var remote = repo.Network.Remotes["origin"];
|
|
|
|
+ if (remote == null) throw new Exception("No remote named 'origin' was found.");
|
|
|
|
+ Commands.Fetch(repo, remote.Name, Array.Empty<string>(), new FetchOptions { CertificateCheck = (certificate, valid, host) => true }, null);
|
|
|
|
+ var localBranch = repo.Head;
|
|
|
|
+ var remoteBranch = repo.Head.TrackedBranch;
|
|
|
|
+ if (remoteBranch == null) throw new Exception("Current branch is not tracking a remote branch.");
|
|
|
|
+ var mergeBase = repo.ObjectDatabase.FindMergeBase(localBranch.Tip, remoteBranch.Tip);
|
|
|
|
+ if (mergeBase == null) throw new Exception("Could not find a common ancestor.");
|
|
|
|
+
|
|
|
|
+ var theirChanges = new HashSet<string>();
|
|
|
|
+ var remoteDiff = repo.Diff.Compare<TreeChanges>(mergeBase.Tree, remoteBranch.Tip.Tree);
|
|
|
|
+ foreach (var change in remoteDiff) theirChanges.Add(change.Path);
|
|
|
|
+
|
|
|
|
+ var ourChanges = new HashSet<string>();
|
|
|
|
+ var localCommitDiff = repo.Diff.Compare<TreeChanges>(mergeBase.Tree, localBranch.Tip.Tree);
|
|
|
|
+ foreach (var change in localCommitDiff) ourChanges.Add(change.Path);
|
|
|
|
+ var localStatus = repo.RetrieveStatus();
|
|
|
|
+ foreach (var statusEntry in localStatus) ourChanges.Add(statusEntry.FilePath);
|
|
|
|
+
|
|
|
|
+ var conflictingFiles = ourChanges.Where(theirChanges.Contains).ToList();
|
|
|
|
|
|
|
|
+ return Task.FromResult(new PullAnalysisResult(conflictingFiles));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private static async void PullExecutor(Action<string> resolve, Action<Exception> reject)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ using var repo = new Repository(projectRoot);
|
|
|
|
+
|
|
|
|
+ // 1. Analyze for conflicts first.
|
|
|
|
+ var analysisResult = await AnalyzePullConflicts(repo);
|
|
|
|
+ if (analysisResult.HasConflicts)
|
|
|
|
+ {
|
|
|
|
+ var conflictFiles = string.Join("\n - ", analysisResult.ConflictingFiles);
|
|
|
|
+ throw new Exception($"Potential conflicts detected in the following files. Please reset or commit your local changes first:\n - {conflictFiles}");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 2. If analysis passes, proceed with the pull.
|
|
var remote = repo.Network.Remotes["origin"];
|
|
var remote = repo.Network.Remotes["origin"];
|
|
if (remote == null) throw new Exception("No remote named 'origin' was found.");
|
|
if (remote == null) throw new Exception("No remote named 'origin' was found.");
|
|
|
|
|
|
- Commands.Fetch(repo, remote.Name, Array.Empty<string>(), new FetchOptions(), "Arbitrator fetch");
|
|
|
|
|
|
+ var signature = new Signature("Better Git Tool", "bettergit@example.com", DateTimeOffset.Now);
|
|
|
|
+ var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (certificate, valid, host) => true } };
|
|
|
|
|
|
- var remoteBranch = repo.Head.TrackedBranch;
|
|
|
|
- if (remoteBranch == null || !remoteBranch.IsRemote)
|
|
|
|
|
|
+ var mergeResult = Commands.Pull(repo, signature, pullOptions);
|
|
|
|
+
|
|
|
|
+ resolve(mergeResult.Status == MergeStatus.UpToDate
|
|
|
|
+ ? "Already up-to-date."
|
|
|
|
+ : $"Pull successful. Status: {mergeResult.Status}");
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ reject(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private static void SafePullExecutor(Action<string> resolve, Action<Exception> reject)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ using var repo = new Repository(projectRoot);
|
|
|
|
+ var signature = new Signature("Better Git Tool", "bettergit@example.com", DateTimeOffset.Now);
|
|
|
|
+ var pullOptions = new PullOptions { FetchOptions = new FetchOptions { CertificateCheck = (certificate, valid, host) => true } };
|
|
|
|
+ var mergeResult = Commands.Pull(repo, signature, pullOptions);
|
|
|
|
+ resolve(mergeResult.Status == MergeStatus.UpToDate ? "Already up-to-date." : $"Pull successful. Status: {mergeResult.Status}");
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ reject(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private static async void ForcePullExecutor(Action<string> resolve, Action<Exception> reject)
|
|
|
|
+ {
|
|
|
|
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ var hasLocalChanges = false;
|
|
|
|
+ var log = new StringBuilder();
|
|
|
|
+ var hasStashed = false;
|
|
|
|
+
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ using (var repo = new Repository(projectRoot))
|
|
{
|
|
{
|
|
- throw new Exception($"Current branch '{repo.Head.FriendlyName}' is not tracking a remote branch.");
|
|
|
|
|
|
+ if (repo.RetrieveStatus().IsDirty) hasLocalChanges = true;
|
|
}
|
|
}
|
|
|
|
+ log.AppendLine($"Step 0: Has local changes? {hasLocalChanges}");
|
|
|
|
|
|
- var diff = repo.Diff.Compare<TreeChanges>(remoteBranch.Tip.Tree, DiffTargets.Index | DiffTargets.WorkingDirectory);
|
|
|
|
|
|
+ if (hasLocalChanges)
|
|
|
|
+ {
|
|
|
|
+ await ExecuteGitCommandAsync("stash push -u -m \"BetterGit-WIP-Pull\"", projectRoot, log, 0, 141);
|
|
|
|
+ hasStashed = true;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // Use the simpler, more robust pull command.
|
|
|
|
+ await ExecuteGitCommandAsync("pull --no-rebase", projectRoot, log, 0, 1, 141);
|
|
|
|
|
|
- foreach (var entry in diff)
|
|
|
|
|
|
+ if (hasStashed)
|
|
{
|
|
{
|
|
- if (entry.Status is ChangeKind.Added or ChangeKind.Deleted or ChangeKind.Modified or ChangeKind.Renamed)
|
|
|
|
- {
|
|
|
|
- changes.Add(new GitChange(entry.Path, entry.OldPath, entry.Status));
|
|
|
|
- }
|
|
|
|
|
|
+ await ExecuteGitCommandAsync("stash pop", projectRoot, log, 0, 1, 141);
|
|
|
|
+ hasStashed = false;
|
|
}
|
|
}
|
|
|
|
|
|
- resolve(changes); // Success
|
|
|
|
|
|
+ resolve(log.ToString());
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
catch (Exception ex)
|
|
{
|
|
{
|
|
- reject(ex); // Failure
|
|
|
|
|
|
+ if (hasStashed)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ await ExecuteGitCommandAsync("stash pop", projectRoot, log, 0, 1, 141);
|
|
|
|
+ }
|
|
|
|
+ catch (Exception exception)
|
|
|
|
+ {
|
|
|
|
+ log.AppendLine($"Fatal Error: {exception.Message}");
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ log.AppendLine("\n--- FAILED ---");
|
|
|
|
+ log.AppendLine(ex.ToString());
|
|
|
|
+ reject(new Exception(log.ToString()));
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// A reusable helper method to execute Git commands with detailed logging and exit code validation.
|
|
|
|
+ /// </summary>
|
|
|
|
+ private static async Task ExecuteGitCommandAsync(string args, string workingDir, StringBuilder log, params int[] acceptableExitCodes)
|
|
|
|
+ {
|
|
|
|
+ var stdOutBuffer = new StringBuilder();
|
|
|
|
+ var stdErrBuffer = new StringBuilder();
|
|
|
|
+ log?.AppendLine($"\n--- Executing: git {args} ---");
|
|
|
|
+
|
|
|
|
+ var command = Cli.Wrap(FindGitExecutable())
|
|
|
|
+ .WithArguments(args)
|
|
|
|
+ .WithWorkingDirectory(workingDir)
|
|
|
|
+ .WithValidation(CommandResultValidation.None) // We handle validation manually
|
|
|
|
+ | (PipeTarget.ToDelegate(x => stdOutBuffer.Append(x)), PipeTarget.ToDelegate(x => stdErrBuffer.Append(x)));
|
|
|
|
+
|
|
|
|
+ var result = await command.ExecuteAsync();
|
|
|
|
+
|
|
|
|
+ log?.AppendLine($"Exit Code: {result.ExitCode}");
|
|
|
|
+ if(stdOutBuffer.Length > 0) log?.AppendLine($"StdOut: {stdOutBuffer}");
|
|
|
|
+ if(stdErrBuffer.Length > 0) log?.AppendLine($"StdErr: {stdErrBuffer}");
|
|
|
|
+
|
|
|
|
+ // If the exit code is not in our list of acceptable codes, it's an error.
|
|
|
|
+ if (!acceptableExitCodes.Contains(result.ExitCode))
|
|
|
|
+ {
|
|
|
|
+ throw new Exception($"Command 'git {args}' failed with unexpected exit code {result.ExitCode}.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
@@ -86,43 +358,75 @@ namespace Terra.Arbitrator.Services
|
|
/// The private logic for the CommitAndPush promise.
|
|
/// The private logic for the CommitAndPush promise.
|
|
/// This code is executed on a background thread.
|
|
/// This code is executed on a background thread.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static void CommitAndPushExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changesToCommit, string commitMessage)
|
|
|
|
|
|
+ private static async void CommitAndPushExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changesToCommit, string commitMessage, string username, string email)
|
|
{
|
|
{
|
|
try
|
|
try
|
|
{
|
|
{
|
|
|
|
+ if (string.IsNullOrWhiteSpace(email))
|
|
|
|
+ {
|
|
|
|
+ throw new Exception("Author email is missing. Please set your email address in Project Settings > Better Git.");
|
|
|
|
+ }
|
|
|
|
+
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
- using var repo = new Repository(projectRoot);
|
|
|
|
|
|
|
|
- var remote = repo.Network.Remotes["origin"];
|
|
|
|
- if (remote == null) throw new Exception("No remote named 'origin' found.");
|
|
|
|
|
|
+ if (projectRoot == null)
|
|
|
|
+ {
|
|
|
|
+ throw new Exception("Could not find project root.");
|
|
|
|
+ }
|
|
|
|
|
|
- Commands.Fetch(repo, remote.Name, Array.Empty<string>(), new FetchOptions(), "Arbitrator pre-push fetch");
|
|
|
|
-
|
|
|
|
- var trackingDetails = repo.Head.TrackingDetails;
|
|
|
|
- if (trackingDetails.BehindBy > 0)
|
|
|
|
|
|
+ using (var repo = new Repository(projectRoot))
|
|
{
|
|
{
|
|
- throw new Exception($"Push aborted. There are {trackingDetails.BehindBy.Value} incoming changes on the remote. Please pull first.");
|
|
|
|
|
|
+ var remote = repo.Network.Remotes["origin"];
|
|
|
|
+ if (remote == null) throw new Exception("No remote named 'origin' found.");
|
|
|
|
+
|
|
|
|
+ var fetchOptions = new FetchOptions { CertificateCheck = (_, _, _) => true };
|
|
|
|
+ Commands.Fetch(repo, remote.Name, Array.Empty<string>(), fetchOptions, "Arbitrator pre-push fetch");
|
|
|
|
+
|
|
|
|
+ var trackingDetails = repo.Head.TrackingDetails;
|
|
|
|
+ if (trackingDetails.BehindBy > 0)
|
|
|
|
+ {
|
|
|
|
+ throw new Exception($"Push aborted. There are {trackingDetails.BehindBy.Value} incoming changes on the remote. Please pull first.");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ var pathsToStage = new List<string>();
|
|
|
|
+ foreach (var change in changesToCommit)
|
|
|
|
+ {
|
|
|
|
+ if (change.Status == ChangeKind.Deleted) Commands.Remove(repo, change.FilePath);
|
|
|
|
+ else pathsToStage.Add(change.FilePath);
|
|
|
|
+ }
|
|
|
|
+ if (pathsToStage.Any()) Commands.Stage(repo, pathsToStage);
|
|
|
|
+
|
|
|
|
+ var status = repo.RetrieveStatus();
|
|
|
|
+ if (!status.IsDirty) throw new Exception("No effective changes were staged to commit.");
|
|
|
|
+
|
|
|
|
+ var author = new Signature(username, email, DateTimeOffset.Now);
|
|
|
|
+ repo.Commit(commitMessage, author, author);
|
|
}
|
|
}
|
|
|
|
|
|
- foreach (var change in changesToCommit)
|
|
|
|
|
|
+ var gitExecutable = FindGitExecutable();
|
|
|
|
+
|
|
|
|
+ var result = await Cli.Wrap(gitExecutable)
|
|
|
|
+ .WithArguments("push")
|
|
|
|
+ .WithWorkingDirectory(projectRoot)
|
|
|
|
+ .WithValidation(CommandResultValidation.None) // Don't throw exceptions automatically
|
|
|
|
+ .ExecuteAsync();
|
|
|
|
+
|
|
|
|
+ // Exit code 141 means the command was successful but was terminated by a SIGPIPE signal. This is safe to ignore on macOS/Linux.
|
|
|
|
+ if (result.ExitCode != 0 && result.ExitCode != 141)
|
|
{
|
|
{
|
|
- if (change.Status == ChangeKind.Deleted) Commands.Remove(repo, change.FilePath);
|
|
|
|
- else Commands.Stage(repo, change.FilePath);
|
|
|
|
|
|
+ throw new Exception($"Push Failed. Error code: {result.ExitCode}");
|
|
}
|
|
}
|
|
|
|
|
|
- var author = new Signature("Arbitrator Tool User", "arbitrator@terabyte.com", DateTimeOffset.Now);
|
|
|
|
- repo.Commit(commitMessage, author, author);
|
|
|
|
-
|
|
|
|
- repo.Network.Push(repo.Head, new PushOptions());
|
|
|
|
-
|
|
|
|
- resolve("Successfully committed and pushed changes!"); // Success
|
|
|
|
|
|
+ resolve("Successfully committed and pushed changes!");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
catch (Exception ex)
|
|
{
|
|
{
|
|
- reject(ex); // Failure
|
|
|
|
|
|
+ Debug.LogException(ex);
|
|
|
|
+ var errorMessage = ex.InnerException?.Message ?? ex.Message;
|
|
|
|
+ reject(new Exception(errorMessage));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
-
|
|
|
|
|
|
+
|
|
/// <summary>
|
|
/// <summary>
|
|
/// The private logic for resetting a file's changes.
|
|
/// The private logic for resetting a file's changes.
|
|
/// This is executed on a background thread.
|
|
/// This is executed on a background thread.
|
|
@@ -178,34 +482,251 @@ namespace Terra.Arbitrator.Services
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
- private static void GetFileDiffExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
|
|
|
|
|
|
+ private static async void LaunchExternalDiffExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
|
|
{
|
|
{
|
|
|
|
+ string fileAPath = null; // Before
|
|
|
|
+ string fileBPath = null; // After
|
|
|
|
+
|
|
try
|
|
try
|
|
{
|
|
{
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ if (projectRoot == null)
|
|
|
|
+ {
|
|
|
|
+ reject(new Exception("Could not find project root."));
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
using var repo = new Repository(projectRoot);
|
|
using var repo = new Repository(projectRoot);
|
|
-
|
|
|
|
- // Compare the HEAD tree with the working directory.
|
|
|
|
- var patch = repo.Diff.Compare<Patch>(repo.Head.Tip.Tree, DiffTargets.WorkingDirectory);
|
|
|
|
|
|
|
|
- // Find the specific change in the complete patch by its path.
|
|
|
|
- // For renames, the 'Path' property holds the new path, which is what we need to look up.
|
|
|
|
- var patchEntry = patch[change.FilePath];
|
|
|
|
|
|
+ // Get the content of a file from a specific commit.
|
|
|
|
+ string GetFileContentFromHead(string path)
|
|
|
|
+ {
|
|
|
|
+ var blob = repo.Head.Tip[path]?.Target as Blob;
|
|
|
|
+ return blob?.GetContentText() ?? "";
|
|
|
|
+ }
|
|
|
|
|
|
- if (patchEntry == null)
|
|
|
|
|
|
+ // Create a temporary file with the correct extension for syntax highlighting.
|
|
|
|
+ string CreateTempFile(string originalPath, string content)
|
|
{
|
|
{
|
|
- // This might happen if the change is in the index but not the working dir.
|
|
|
|
- // Fallback to checking the index as well.
|
|
|
|
- patch = repo.Diff.Compare<Patch>(repo.Head.Tip.Tree, DiffTargets.Index);
|
|
|
|
- patchEntry = patch[change.FilePath];
|
|
|
|
|
|
+ var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(originalPath));
|
|
|
|
+ File.WriteAllText(tempPath, content);
|
|
|
|
+ return tempPath;
|
|
}
|
|
}
|
|
|
|
|
|
- resolve(patchEntry?.Patch ?? $"No textual changes detected for {change.FilePath}. Status: {change.Status}");
|
|
|
|
|
|
+ switch (change.Status)
|
|
|
|
+ {
|
|
|
|
+ case ChangeKind.Added:
|
|
|
|
+ fileAPath = CreateTempFile(change.FilePath, ""); // Empty "before" file
|
|
|
|
+ fileBPath = Path.Combine(projectRoot, change.FilePath);
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ case ChangeKind.Deleted:
|
|
|
|
+ fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
|
|
|
|
+ fileBPath = CreateTempFile(change.FilePath, ""); // Empty "after" file
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ case ChangeKind.Renamed:
|
|
|
|
+ fileAPath = CreateTempFile(change.OldFilePath, GetFileContentFromHead(change.OldFilePath));
|
|
|
|
+ fileBPath = Path.Combine(projectRoot, change.FilePath);
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ case ChangeKind.Unmodified:
|
|
|
|
+ case ChangeKind.Modified:
|
|
|
|
+ case ChangeKind.Copied:
|
|
|
|
+ case ChangeKind.Ignored:
|
|
|
|
+ case ChangeKind.Untracked:
|
|
|
|
+ case ChangeKind.TypeChanged:
|
|
|
|
+ case ChangeKind.Unreadable:
|
|
|
|
+ case ChangeKind.Conflicted:
|
|
|
|
+ default: // Modified
|
|
|
|
+ fileAPath = CreateTempFile(change.FilePath, GetFileContentFromHead(change.FilePath));
|
|
|
|
+ fileBPath = Path.Combine(projectRoot, change.FilePath);
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ var vsCodeExecutable = FindVsCodeExecutable();
|
|
|
|
+
|
|
|
|
+ await Cli.Wrap(vsCodeExecutable)
|
|
|
|
+ .WithArguments(args => args
|
|
|
|
+ .Add("--diff")
|
|
|
|
+ .Add(fileAPath)
|
|
|
|
+ .Add(fileBPath))
|
|
|
|
+ .ExecuteAsync();
|
|
|
|
+
|
|
|
|
+ resolve("Launched external diff tool.");
|
|
|
|
+ }
|
|
|
|
+ catch(Win32Exception ex)
|
|
|
|
+ {
|
|
|
|
+ // This specific exception is thrown if the executable is not found.
|
|
|
|
+ reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
|
|
}
|
|
}
|
|
catch(Exception ex)
|
|
catch(Exception ex)
|
|
{
|
|
{
|
|
reject(ex);
|
|
reject(ex);
|
|
}
|
|
}
|
|
|
|
+ finally
|
|
|
|
+ {
|
|
|
|
+ // We only delete files we created (i.e., not the actual project file).
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ if (fileAPath != null && fileAPath.Contains(Path.GetTempPath()) && File.Exists(fileAPath))
|
|
|
|
+ {
|
|
|
|
+ File.Delete(fileAPath);
|
|
|
|
+ }
|
|
|
|
+ if (fileBPath != null && fileBPath.Contains(Path.GetTempPath()) && File.Exists(fileBPath))
|
|
|
|
+ {
|
|
|
|
+ File.Delete(fileBPath);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ catch(Exception cleanupEx)
|
|
|
|
+ {
|
|
|
|
+ // Log cleanup error but don't throw, as the primary exception (if any) is more important.
|
|
|
|
+ Debug.LogError($"Failed to clean up temporary diff files: {cleanupEx.Message}");
|
|
|
|
+ }
|
|
|
|
+ }
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// The private logic for launching a merge tool for a conflicted file.
|
|
|
|
+ /// </summary>
|
|
|
|
+ private static async void LaunchMergeToolExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
|
|
|
|
+ {
|
|
|
|
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ if (projectRoot == null)
|
|
|
|
+ {
|
|
|
|
+ reject(new Exception("Could not find project root."));
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+ if (change.FilePath == null)
|
|
|
|
+ {
|
|
|
|
+ reject(new Exception("Could not find file path."));
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ var fileExtension = Path.GetExtension(change.FilePath)?.ToLower();
|
|
|
|
+ if (fileExtension is ".prefab" or ".unity")
|
|
|
|
+ {
|
|
|
|
+ reject(new Exception("Cannot resolve conflicts for binary files (.prefab, .unity) with VS Code. Please use an external merge tool."));
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // Launch VS Code and wait for it to close.
|
|
|
|
+ var vsCodeExecutable = FindVsCodeExecutable();
|
|
|
|
+ await Cli.Wrap(vsCodeExecutable)
|
|
|
|
+ .WithArguments(args => args
|
|
|
|
+ .Add("--wait") // Crucial flag
|
|
|
|
+ .Add(change.FilePath))
|
|
|
|
+ .WithWorkingDirectory(projectRoot)
|
|
|
|
+ .ExecuteAsync();
|
|
|
|
+
|
|
|
|
+ var fullPath = Path.Combine(projectRoot, change.FilePath);
|
|
|
|
+ var fileContent = await File.ReadAllTextAsync(fullPath);
|
|
|
|
+
|
|
|
|
+ if (fileContent.Contains("<<<<<<<") || fileContent.Contains("=======") || fileContent.Contains(">>>>>>>"))
|
|
|
|
+ {
|
|
|
|
+ // The user closed the editor but did not resolve the conflict.
|
|
|
|
+ resolve($"Conflict in '{change.FilePath}' was not resolved. Please try again.");
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ var gitExecutable = FindGitExecutable();
|
|
|
|
+
|
|
|
|
+ // The markers are gone, so now we can tell Git the conflict is resolved.
|
|
|
|
+ await Cli.Wrap(gitExecutable)
|
|
|
|
+ .WithArguments($"add \"{change.FilePath}\"")
|
|
|
|
+ .WithWorkingDirectory(projectRoot)
|
|
|
|
+ .ExecuteAsync();
|
|
|
|
+
|
|
|
|
+ // The conflict is resolved and staged. Now, unstage the file to give the user control.
|
|
|
|
+ await Cli.Wrap(gitExecutable)
|
|
|
|
+ .WithArguments($"reset HEAD \"{change.FilePath}\"")
|
|
|
|
+ .WithWorkingDirectory(projectRoot)
|
|
|
|
+ .ExecuteAsync();
|
|
|
|
+
|
|
|
|
+ resolve($"Successfully resolved conflict in '{change.FilePath}'. The file is now modified and ready for review.");
|
|
|
|
+ }
|
|
|
|
+ catch (Win32Exception ex)
|
|
|
|
+ {
|
|
|
|
+ reject(new Exception("Could not launch VS Code. Ensure it is installed and the 'code' command is available in your system's PATH.", ex));
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ reject(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// Executor for UnstageAllFilesIfSafe. Checks for conflicts and staged files, then unstages if appropriate.
|
|
|
|
+ /// </summary>
|
|
|
|
+ private static async void UnstageAllFilesIfSafeExecutor(Action<bool> resolve, Action<Exception> reject)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
+ if (projectRoot == null)
|
|
|
|
+ {
|
|
|
|
+ reject(new Exception("Could not find project root."));
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+ using var repo = new Repository(projectRoot);
|
|
|
|
+
|
|
|
|
+ // Safety Check: Do not proceed if there are any conflicts.
|
|
|
|
+ if (repo.Index.Conflicts.Any())
|
|
|
|
+ {
|
|
|
|
+ resolve(false);
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // Check if there are any files staged for commit.
|
|
|
|
+ var stagedFiles = repo.RetrieveStatus().Where(s => s.State is
|
|
|
|
+ FileStatus.NewInIndex or
|
|
|
|
+ FileStatus.ModifiedInIndex or
|
|
|
|
+ FileStatus.DeletedFromIndex or
|
|
|
|
+ FileStatus.RenamedInIndex or
|
|
|
|
+ FileStatus.TypeChangeInIndex);
|
|
|
|
+
|
|
|
|
+ if (!stagedFiles.Any())
|
|
|
|
+ {
|
|
|
|
+ resolve(false); // Nothing to do.
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // If we get here, it's safe to unstage everything.
|
|
|
|
+ await ExecuteGitCommandAsync("reset", projectRoot, null, 0, 141);
|
|
|
|
+ resolve(true); // Signal that files were unstaged.
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ reject(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// Searches for an executable in common macOS PATH directories.
|
|
|
|
+ /// </summary>
|
|
|
|
+ private static string FindExecutable(string name)
|
|
|
|
+ {
|
|
|
|
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
|
|
+ {
|
|
|
|
+ // CliWrap handles PATH search on Windows automatically.
|
|
|
|
+ return name;
|
|
|
|
+ }
|
|
|
|
+ // For macOS/Linux, we need to be more explicit due to Unity's sandboxing.
|
|
|
|
+ string[] searchPaths = { "/usr/local/bin", "/usr/bin", "/bin", "/opt/homebrew/bin" };
|
|
|
|
+ foreach (var path in searchPaths)
|
|
|
|
+ {
|
|
|
|
+ var fullPath = Path.Combine(path, name);
|
|
|
|
+ if (File.Exists(fullPath))
|
|
|
|
+ {
|
|
|
|
+ return fullPath;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ throw new FileNotFoundException($"Could not find executable '{name}'. Please ensure it is installed and in your system's PATH.");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private static string FindVsCodeExecutable() => FindExecutable("code");
|
|
|
|
+ private static string FindGitExecutable() => FindExecutable("git");
|
|
}
|
|
}
|
|
}
|
|
}
|