// 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. using System; using System.IO; using UnityEngine; using LibGit2Sharp; using Terra.Arbitrator.Promises; using System.Collections.Generic; namespace Terra.Arbitrator { public static class GitService { // Public method that returns the promise public static IPromise> CompareLocalToRemote() { return new Promise>(CompareExecutor); } // Public method that returns the promise public static IPromise CommitAndPush(List changesToCommit, string commitMessage) { // Use a lambda here to pass arguments to the executor method return new Promise((resolve, reject) => CommitAndPushExecutor(resolve, reject, changesToCommit, commitMessage)); } // Creates a promise to revert a single file to its HEAD revision. public static IPromise ResetFileChanges(GitChange changeToReset) { return new Promise((resolve, reject) => ResetFileExecutor(resolve, reject, changeToReset)); } // Creates a promise to get the diff patch for a single file against HEAD. public static IPromise GetFileDiff(GitChange change) { return new Promise((resolve, reject) => GetFileDiffExecutor(resolve, reject, change)); } /// /// The private logic for the CompareLocalToRemote promise. /// This code is executed on a background thread by the Promise constructor. /// private static void CompareExecutor(Action> resolve, Action reject) { try { var changes = new List(); 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' was found."); Commands.Fetch(repo, remote.Name, Array.Empty(), new FetchOptions(), "Arbitrator fetch"); var remoteBranch = repo.Head.TrackedBranch; if (remoteBranch == null || !remoteBranch.IsRemote) { throw new Exception($"Current branch '{repo.Head.FriendlyName}' is not tracking a remote branch."); } var diff = repo.Diff.Compare(remoteBranch.Tip.Tree, DiffTargets.Index | DiffTargets.WorkingDirectory); foreach (var entry in diff) { if (entry.Status == ChangeKind.Added || entry.Status == ChangeKind.Deleted || entry.Status == ChangeKind.Modified) { changes.Add(new GitChange(entry.Path, entry.Status)); } } resolve(changes); // Success } catch (Exception ex) { reject(ex); // Failure } } /// /// The private logic for the CommitAndPush promise. /// This code is executed on a background thread. /// private static void CommitAndPushExecutor(Action resolve, Action reject, List changesToCommit, string commitMessage) { try { 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."); Commands.Fetch(repo, remote.Name, Array.Empty(), new 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."); } foreach (var change in changesToCommit) { if (change.Status == ChangeKind.Deleted) Commands.Remove(repo, change.FilePath); else Commands.Stage(repo, change.FilePath); } 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 } catch (Exception ex) { reject(ex); // Failure } } /// /// The private logic for resetting a file's changes. /// This is executed on a background thread. /// private static void ResetFileExecutor(Action resolve, Action reject, GitChange changeToReset) { 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 { // 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); } } private static void GetFileDiffExecutor(Action resolve, Action reject, GitChange change) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; using var repo = new Repository(projectRoot); // Use Compare() against the HEAD commit to get the patch for the specific file. var diff = repo.Diff.Compare(new[] { change.FilePath }, true); resolve(diff.Content); } catch(Exception ex) { reject(ex); } } } }