|
@@ -1,768 +1,109 @@
|
|
// Copyright (c) 2025 TerraByte Inc.
|
|
// Copyright (c) 2025 TerraByte Inc.
|
|
//
|
|
//
|
|
-// This script contains the core business logic for interacting with the Git
|
|
|
|
-// repository. All public methods are asynchronous and return a promise.
|
|
|
|
|
|
+// This script serves as the clean, public-facing API for all Git operations.
|
|
|
|
+// It creates promises and delegates the actual implementation work to the
|
|
|
|
+// internal GitExecutors class.
|
|
|
|
|
|
-using System;
|
|
|
|
-using CliWrap;
|
|
|
|
-using System.IO;
|
|
|
|
-using System.Linq;
|
|
|
|
-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 Terra.Arbitrator.Settings;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Generic;
|
|
-using System.Runtime.InteropServices;
|
|
|
|
-using Debug = UnityEngine.Debug;
|
|
|
|
|
|
|
|
namespace Terra.Arbitrator.Services
|
|
namespace Terra.Arbitrator.Services
|
|
{
|
|
{
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// The public static API for interacting with the Git repository.
|
|
|
|
+ /// </summary>
|
|
public static class GitService
|
|
public static class GitService
|
|
{
|
|
{
|
|
- // Public method that returns the promise
|
|
|
|
- public static IPromise<List<GitChange>> CompareLocalToRemote()
|
|
|
|
- {
|
|
|
|
- return new Promise<List<GitChange>>(GetLocalStatusExecutor);
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // Public method that returns the promise
|
|
|
|
- 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
|
|
|
|
- return new Promise<string>((resolve, reject) =>
|
|
|
|
- CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage, username, email));
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // Creates a promise to revert a single file to its HEAD revision.
|
|
|
|
- public static IPromise<string> ResetFileChanges(GitChange changeToReset)
|
|
|
|
- {
|
|
|
|
- return new Promise<string>((resolve, reject) =>
|
|
|
|
- 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));
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // 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>(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>
|
|
/// <summary>
|
|
- /// Creates a promise to unstage all files if the repository is in a clean, non-conflicted state.
|
|
|
|
|
|
+ /// Synchronously gets the Git status for a single file.
|
|
|
|
+ /// Required by UI elements that cannot easily use promises.
|
|
/// </summary>
|
|
/// </summary>
|
|
- public static IPromise<bool> UnstageAllFilesIfSafe()
|
|
|
|
|
|
+ public static GitChange GetChangeForFile(string filePath)
|
|
{
|
|
{
|
|
- return new Promise<bool>(UnstageAllFilesIfSafeExecutor);
|
|
|
|
|
|
+ return GitExecutors.GetChangeForFile(filePath);
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// Creates a promise to discard all local changes (tracked and untracked).
|
|
|
|
|
|
+ /// Compares the local repository state to the tracked remote branch.
|
|
/// </summary>
|
|
/// </summary>
|
|
- public static IPromise<string> ResetAllChanges()
|
|
|
|
|
|
+ public static IPromise<List<GitChange>> CompareLocalToRemote()
|
|
{
|
|
{
|
|
- return new Promise<string>(ResetAllChangesExecutor);
|
|
|
|
|
|
+ return new Promise<List<GitChange>>(GitExecutors.GetLocalStatusExecutor);
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// Synchronous helper to get a GitChange object for a single file.
|
|
|
|
- /// Needed by the static persistence script which cannot easily use promises.
|
|
|
|
|
|
+ /// Commits and pushes the selected files to the remote repository.
|
|
/// </summary>
|
|
/// </summary>
|
|
- public static GitChange GetChangeForFile(string filePath)
|
|
|
|
|
|
+ public static IPromise<string> CommitAndPush(List<GitChange> changesToCommit, string commitMessage, string username, string email)
|
|
{
|
|
{
|
|
- 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
|
|
|
|
|
|
+ return new Promise<string>((resolve, reject) =>
|
|
|
|
+ GitExecutors.CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage, username, email));
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// Gets the status of the local repository, showing unstaged changes.
|
|
|
|
- /// This is the equivalent of 'git status'.
|
|
|
|
|
|
+ /// Reverts all local changes for a single file.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static void GetLocalStatusExecutor(Action<List<GitChange>> resolve, Action<Exception> reject)
|
|
|
|
- {
|
|
|
|
- try
|
|
|
|
- {
|
|
|
|
- var changes = new List<GitChange>();
|
|
|
|
- var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
- 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"];
|
|
|
|
- if (remote == null) throw new Exception("No remote named 'origin' was found.");
|
|
|
|
-
|
|
|
|
- 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 void SafePullExecutor(Action<string> resolve, Action<Exception> reject)
|
|
|
|
|
|
+ public static IPromise<string> ResetFileChanges(GitChange changeToReset)
|
|
{
|
|
{
|
|
- 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);
|
|
|
|
- }
|
|
|
|
|
|
+ return new Promise<string>((resolve, reject) =>
|
|
|
|
+ GitExecutors.ResetFileExecutor(resolve, reject, changeToReset));
|
|
}
|
|
}
|
|
-
|
|
|
|
- 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))
|
|
|
|
- {
|
|
|
|
- if (repo.RetrieveStatus().IsDirty) hasLocalChanges = true;
|
|
|
|
- }
|
|
|
|
- log.AppendLine($"Step 0: Has local changes? {hasLocalChanges}");
|
|
|
|
-
|
|
|
|
- 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);
|
|
|
|
|
|
|
|
- if (hasStashed)
|
|
|
|
- {
|
|
|
|
- await ExecuteGitCommandAsync("stash pop", projectRoot, log, 0, 1, 141);
|
|
|
|
- hasStashed = false;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- resolve(log.ToString());
|
|
|
|
- }
|
|
|
|
- catch (Exception ex)
|
|
|
|
- {
|
|
|
|
- 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>
|
|
/// <summary>
|
|
- /// A reusable helper method to execute Git commands with detailed logging and exit code validation.
|
|
|
|
|
|
+ /// Launches an external diff tool to compare file versions.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static async Task ExecuteGitCommandAsync(string args, string workingDir, StringBuilder log, params int[] acceptableExitCodes)
|
|
|
|
|
|
+ public static IPromise<string> LaunchExternalDiff(GitChange change)
|
|
{
|
|
{
|
|
- 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}.");
|
|
|
|
- }
|
|
|
|
|
|
+ return new Promise<string>((resolve, reject) => GitExecutors.LaunchExternalDiffExecutor(resolve, reject, change));
|
|
}
|
|
}
|
|
-
|
|
|
|
|
|
+
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// The private logic for the CommitAndPush promise.
|
|
|
|
- /// This code is executed on a background thread.
|
|
|
|
|
|
+ /// Analyzes if a pull operation results in conflicts.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static async void CommitAndPushExecutor(Action<string> resolve, Action<Exception> reject, List<GitChange> changesToCommit, string commitMessage, string username, string email)
|
|
|
|
|
|
+ public static IPromise<PullAnalysisResult> AnalyzePullConflicts()
|
|
{
|
|
{
|
|
- 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;
|
|
|
|
-
|
|
|
|
- if (projectRoot == null)
|
|
|
|
- {
|
|
|
|
- throw new Exception("Could not find project root.");
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- using (var repo = new Repository(projectRoot))
|
|
|
|
- {
|
|
|
|
- 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);
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- 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)
|
|
|
|
- {
|
|
|
|
- throw new Exception($"Push Failed. Error code: {result.ExitCode}");
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- resolve("Successfully committed and pushed changes!");
|
|
|
|
- }
|
|
|
|
- catch (Exception ex)
|
|
|
|
- {
|
|
|
|
- Debug.LogException(ex);
|
|
|
|
- var errorMessage = ex.InnerException?.Message ?? ex.Message;
|
|
|
|
- reject(new Exception(errorMessage));
|
|
|
|
- }
|
|
|
|
|
|
+ return new Promise<PullAnalysisResult>(GitExecutors.FileLevelConflictCheckExecutor);
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// The private logic for resetting a file's changes.
|
|
|
|
- /// This is executed on a background thread.
|
|
|
|
|
|
+ /// Performs a "safe" pull, assuming no conflicts will occur.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static void ResetFileExecutor(Action<string> resolve, Action<Exception> reject, GitChange changeToReset)
|
|
|
|
|
|
+ public static IPromise<string> PerformSafePull()
|
|
{
|
|
{
|
|
- try
|
|
|
|
- {
|
|
|
|
- var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
- using var repo = new Repository(projectRoot);
|
|
|
|
-
|
|
|
|
- // For 'Added' files, checking out doesn't remove them. We need to unstage and delete.
|
|
|
|
- if (changeToReset.Status == ChangeKind.Added)
|
|
|
|
- {
|
|
|
|
- // Unstage the file from the index.
|
|
|
|
- Commands.Unstage(repo, changeToReset.FilePath);
|
|
|
|
-
|
|
|
|
- // And delete it from the working directory.
|
|
|
|
- if (projectRoot != null)
|
|
|
|
- {
|
|
|
|
- var fullPath = Path.Combine(projectRoot, changeToReset.FilePath);
|
|
|
|
- if (File.Exists(fullPath))
|
|
|
|
- {
|
|
|
|
- File.Delete(fullPath);
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- else if (changeToReset.Status == ChangeKind.Renamed)
|
|
|
|
- {
|
|
|
|
- // 1. Unstage the new path
|
|
|
|
- Commands.Unstage(repo, changeToReset.FilePath);
|
|
|
|
- // 2. Delete the new file from the working directory
|
|
|
|
- if (projectRoot != null)
|
|
|
|
- {
|
|
|
|
- var newFullPath = Path.Combine(projectRoot, changeToReset.FilePath);
|
|
|
|
- if (File.Exists(newFullPath)) File.Delete(newFullPath);
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // 3. Checkout the old path from the HEAD to restore it
|
|
|
|
- repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.OldFilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
|
|
|
|
- }
|
|
|
|
- else
|
|
|
|
- {
|
|
|
|
- // For Modified or Deleted files, CheckoutPaths is the correct command to revert them.
|
|
|
|
- repo.CheckoutPaths(repo.Head.Tip.Sha, new[] { changeToReset.FilePath }, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- resolve($"Successfully reset changes for '{changeToReset.FilePath}'");
|
|
|
|
- }
|
|
|
|
- catch (Exception ex)
|
|
|
|
- {
|
|
|
|
- reject(ex);
|
|
|
|
- }
|
|
|
|
|
|
+ return new Promise<string>(GitExecutors.SafePullExecutor);
|
|
}
|
|
}
|
|
-
|
|
|
|
- private static async void LaunchExternalDiffExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
|
|
|
|
- {
|
|
|
|
- string fileAPath = null; // Before
|
|
|
|
- string fileBPath = null; // After
|
|
|
|
-
|
|
|
|
- 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);
|
|
|
|
-
|
|
|
|
- // 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() ?? "";
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // Create a temporary file with the correct extension for syntax highlighting.
|
|
|
|
- string CreateTempFile(string originalPath, string content)
|
|
|
|
- {
|
|
|
|
- var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(originalPath));
|
|
|
|
- File.WriteAllText(tempPath, content);
|
|
|
|
- return tempPath;
|
|
|
|
- }
|
|
|
|
|
|
|
|
- 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)
|
|
|
|
- {
|
|
|
|
- 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>
|
|
/// <summary>
|
|
- /// The private logic for launching a merge tool for a conflicted file.
|
|
|
|
|
|
+ /// Performs a pull that may result in merge conflicts.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static async void LaunchMergeToolExecutor(Action<string> resolve, Action<Exception> reject, GitChange change)
|
|
|
|
|
|
+ public static IPromise<string> ForcePull()
|
|
{
|
|
{
|
|
- 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);
|
|
|
|
- }
|
|
|
|
|
|
+ return new Promise<string>(GitExecutors.ForcePullExecutor);
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// Executor for UnstageAllFilesIfSafe. Checks for conflicts and staged files, then unstages if appropriate.
|
|
|
|
|
|
+ /// Launches an external merge tool for a conflicted file.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static async void UnstageAllFilesIfSafeExecutor(Action<bool> resolve, Action<Exception> reject)
|
|
|
|
|
|
+ public static IPromise<string> LaunchMergeTool(GitChange change)
|
|
{
|
|
{
|
|
- 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);
|
|
|
|
- }
|
|
|
|
|
|
+ return new Promise<string>((resolve, reject) => GitExecutors.LaunchMergeToolExecutor(resolve, reject, change));
|
|
}
|
|
}
|
|
-
|
|
|
|
|
|
+
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// Executor for ResetAllChanges. Discards all local changes.
|
|
|
|
|
|
+ /// Unstages all files if the repository is in a clean, non-conflicted state.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static async void ResetAllChangesExecutor(Action<string> resolve, Action<Exception> reject)
|
|
|
|
|
|
+ public static IPromise<bool> UnstageAllFilesIfSafe()
|
|
{
|
|
{
|
|
- try
|
|
|
|
- {
|
|
|
|
- var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
|
|
|
- if (projectRoot == null)
|
|
|
|
- {
|
|
|
|
- reject(new Exception("Could not find project root."));
|
|
|
|
- return;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // 1. Discard all changes to tracked files
|
|
|
|
- await ExecuteGitCommandAsync("reset --hard HEAD", projectRoot, null, 0);
|
|
|
|
-
|
|
|
|
- // 2. Remove all untracked files and directories
|
|
|
|
- await ExecuteGitCommandAsync("clean -fd", projectRoot, null, 0);
|
|
|
|
-
|
|
|
|
- resolve("Successfully discarded all local changes.");
|
|
|
|
- }
|
|
|
|
- catch (Exception ex)
|
|
|
|
- {
|
|
|
|
- reject(ex);
|
|
|
|
- }
|
|
|
|
|
|
+ return new Promise<bool>(GitExecutors.UnstageAllFilesIfSafeExecutor);
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
- /// Searches for an executable in common macOS PATH directories.
|
|
|
|
|
|
+ /// Discards all local changes (tracked and untracked) in the repository.
|
|
/// </summary>
|
|
/// </summary>
|
|
- private static string FindExecutable(string name)
|
|
|
|
|
|
+ public static IPromise<string> ResetAllChanges()
|
|
{
|
|
{
|
|
- 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.");
|
|
|
|
|
|
+ return new Promise<string>(GitExecutors.ResetAllChangesExecutor);
|
|
}
|
|
}
|
|
-
|
|
|
|
- private static string FindVsCodeExecutable() => FindExecutable("code");
|
|
|
|
- private static string FindGitExecutable() => FindExecutable("git");
|
|
|
|
}
|
|
}
|
|
-}
|
|
|
|
|
|
+}
|