// 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 LibGit2Sharp; using UnityEngine; using Terra.Arbitrator.Promises; using System.Collections.Generic; namespace Terra.Arbitrator.Services { 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 is ChangeKind.Added or ChangeKind.Deleted or ChangeKind.Modified or ChangeKind.Renamed) { changes.Add(new GitChange(entry.Path, entry.OldPath, 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 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); } } private static void GetFileDiffExecutor(Action resolve, Action reject, GitChange change) { try { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName; using var repo = new Repository(projectRoot); // Compare the HEAD tree with the working directory. var patch = repo.Diff.Compare(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]; if (patchEntry == null) { // 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(repo.Head.Tip.Tree, DiffTargets.Index); patchEntry = patch[change.FilePath]; } resolve(patchEntry?.Patch ?? $"No textual changes detected for {change.FilePath}. Status: {change.Status}"); } catch(Exception ex) { reject(ex); } } } }