Editor UI component for Git integration in the project editor. It displays commit history, file changes, and provides actions: init repository, set remote, pull, push, commit staged files, restore files from a commit, and reads files at revisions by running git CLI processes in the project folder.
using Editor;
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Button = Editor.Button;
using Label = Editor.Label;
public partial class LocalControlWidget
{
private class GitCommitView
{
public string Hash;
public string Date;
public string Message;
public List<string> Files;
}
// --- History UI ---
private void RefreshGitHistoryUI( Widget canvas )
{
if ( !EnsureGitRepository() )
{
canvas.Layout.Add( new Label( "No Git repository found." ) );
canvas.Layout.AddStretchCell();
return;
}
var unpushedSet = new HashSet<string>();
var (syncOk, syncOut, _) = RunGitCommand( "rev-list HEAD --not --remotes" );
if ( syncOk && !string.IsNullOrWhiteSpace( syncOut ) )
{
foreach ( var h in syncOut.Split( new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries ) )
{
unpushedSet.Add( h.Trim().ToLower() );
}
}
var (ok, output, error) = RunGitCommand( "log --date=format:%Y-%m-%d_%H:%M --pretty=format:%H|%ad|%s --name-only --max-count=30" );
if ( !ok )
{
canvas.Layout.Add( new Label( $"Git log failed: {error}" ) );
canvas.Layout.AddStretchCell();
return;
}
var lines = output.Split( new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None );
var commits = new List<GitCommitView>();
GitCommitView current = null;
foreach ( var raw in lines )
{
var line = raw?.TrimEnd() ?? "";
if ( string.IsNullOrWhiteSpace( line ) ) continue;
if ( line.Count( c => c == '|' ) >= 2 )
{
var parts = line.Split( '|', 3 );
string dateString = parts[1];
var dateParts = dateString.Split( '_' );
string formattedTimestamp = dateString.Replace( "_", " " );
if ( dateParts.Length >= 2 )
formattedTimestamp = $"{dateParts[0]} <u>{dateParts[1]}</u>";
current = new GitCommitView
{
Hash = parts[0].Trim().ToLower(),
Date = formattedTimestamp,
Message = parts[2],
Files = new List<string>()
};
commits.Add( current );
}
else if ( current != null )
{
current.Files.Add( line.Trim() );
}
}
foreach ( var commit in commits )
{
var card = new Widget( canvas );
card.Layout = Layout.Column();
card.Layout.Margin = new Margin( 10, 8 );
card.Layout.Spacing = 4;
card.Layout.Add( new Label( $"<b>💬 {commit.Message}</b>", card ) { WordWrap = true } );
var actionRow = card.Layout.AddRow();
actionRow.Spacing = 5;
actionRow.Add( new Label( $"<small><font color='#ffffff'>{commit.Date}</font></small>", card ) );
string shortHash = commit.Hash.Length > 7 ? commit.Hash.Substring( 0, 7 ) : commit.Hash;
string webUrl = GetCommitWebUrl( commit.Hash );
bool isPushed = !unpushedSet.Contains( commit.Hash ) && !string.IsNullOrEmpty( webUrl );
var hashLabel = new ClickableLabel( string.Empty, card );
if ( isPushed )
{
hashLabel.Text = $"<small><font color='#55aaff'><b><u>• {shortHash}</u></b></font></small>";
hashLabel.ToolTip = $"Double click to open commit {shortHash} on Web";
hashLabel.DoubleClicked = () => Process.Start( new ProcessStartInfo( webUrl ) { UseShellExecute = true } );
}
else
{
hashLabel.Text = $"<small><font color='#bbbbbb'><b><u>• {shortHash}</u></b></font></small>";
hashLabel.ToolTip = string.IsNullOrEmpty( webUrl ) ? "Set remote to enable links" : "Commit local only (Not Pushed)";
}
actionRow.Add( hashLabel );
actionRow.AddStretchCell();
var viewBtn = new Button( "View", "visibility" );
actionRow.Add( viewBtn );
var filesContainer = new Widget( card ) { Visible = false };
filesContainer.Layout = Layout.Column();
filesContainer.Layout.Margin = new Margin( 20, 5, 0, 5 );
filesContainer.Layout.Spacing = 8;
viewBtn.Clicked += () => {
filesContainer.Visible = !filesContainer.Visible;
viewBtn.Text = filesContainer.Visible ? "Hide" : "View";
viewBtn.Icon = filesContainer.Visible ? "visibility_off" : "visibility";
};
var capturedHash = commit.Hash;
var capturedMsg = commit.Message;
var restoreBtn = new Button( "Restore to this state", "settings_backup_restore" );
restoreBtn.ToolTip = "Restores all files to their state in this commit. This does not move your branch.\nRestored files will appear in Changes for you to review and commit.";
restoreBtn.Clicked += () => OnGitRestoreClicked( capturedHash, capturedMsg );
filesContainer.Layout.AddRow().Add( restoreBtn, 1 );
filesContainer.Layout.AddSeparator();
filesContainer.Layout.Add( new Label( $"<small><b>{commit.Files.Count} file(s) changed:</b></small>" ) );
var filesToShow = commit.Files.Take( MAX_FILES_DISPLAYED ).ToList();
foreach ( var f in filesToShow )
{
filesContainer.Layout.Add( new Label( $"<small> 📄 {f}</small>" ) );
}
if ( commit.Files.Count > MAX_FILES_DISPLAYED )
{
filesContainer.Layout.Add( new Label( $"<small><i> ... and {commit.Files.Count - MAX_FILES_DISPLAYED} more files.</i></small>" ) );
}
card.Layout.Add( filesContainer );
canvas.Layout.Add( card );
canvas.Layout.AddSeparator();
}
canvas.Layout.AddStretchCell();
}
// --- Restore ---
private void OnGitRestoreClicked( string hash, string commitMessage )
{
if ( !EnsureGitRepository() ) return;
var (statusOk, statusOut, _) = RunGitCommand( "status --porcelain" );
bool hasUncommitted = statusOk && !string.IsNullOrWhiteSpace( statusOut );
string prompt = hasUncommitted
? $"You have uncommitted changes that will be overwritten.\n\nRestore files to commit '{commitMessage}' ({hash})?\n\nThis does not move your branch — restored files will appear in Changes for you to review and commit."
: $"Restore files to commit '{commitMessage}' ({hash})?\n\nThis does not move your branch — restored files will appear in Changes for you to review and commit.";
Editor.Dialog.AskConfirm( () => PerformGitRestore( hash ), prompt, "Restore Git Commit?" );
}
private void PerformGitRestore( string hash )
{
var (restoreOk, _, restoreError) = RunGitCommand( $"restore --source={QuoteArg( hash )} --worktree -- ." );
if ( !restoreOk )
{
var (checkoutOk, _, checkoutError) = RunGitCommand( $"checkout {QuoteArg( hash )} -- ." );
if ( !checkoutOk )
{
var finalError = string.IsNullOrWhiteSpace( restoreError ) ? checkoutError : restoreError;
Log.Error( $"Git restore failed: {finalError}" );
ShowOperationNotification( "Restore Failed", string.IsNullOrWhiteSpace( finalError ) ? "Unable to restore commit." : finalError, "error", Theme.Red );
return;
}
}
RunGitCommand( "reset -- ." );
ShowOperationNotification( "Restore Applied", "Files restored — review and commit when ready", "settings_backup_restore", Theme.Green );
ShowTab( true );
}
// --- Changes ---
private void RefreshGitChanges( Widget canvas )
{
if ( !EnsureGitRepository() )
{
var initRow = canvas.Layout.AddRow();
initRow.Add( new Label( "No Git repository found in this project." ) );
var initBtn = new Button( "Init Git Now", "create_new_folder" );
initBtn.Clicked += () => { OnInitGitClicked(); OnRefreshClicked(); };
canvas.Layout.AddSpacingCell( 8 );
canvas.Layout.Add( initBtn );
return;
}
var (ok, output, error) = RunGitCommand( "status --porcelain -uall" );
if ( !ok )
{
canvas.Layout.Add( new Label( $"Git status failed: {error}" ) );
return;
}
var lines = output.Split( new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries );
if ( lines.Length == 0 )
{
canvas.Layout.Add( new Label( "No changed files." ) );
return;
}
foreach ( var line in lines )
{
if ( !TryParseGitStatusLine( line, out var code, out var path ) ) continue;
AddChangeItem( canvas, path, MapGitStatus( code ) );
}
}
private bool TryParseGitStatusLine(string line, out string code, out string path)
{
code = ""; path = "";
if (string.IsNullOrWhiteSpace(line) || line.Length < 3) return false;
code = line.Substring(0, 2);
path = line.Substring(2).TrimStart();
const string renameIndicator = "->";
int renameIdx = path.IndexOf(renameIndicator, StringComparison.Ordinal);
if (renameIdx >= 0) path = path.Substring(renameIdx + renameIndicator.Length).Trim();
if (path.StartsWith("\"") && path.EndsWith("\"") && path.Length >= 2)
path = path.Substring(1, path.Length - 2);
path = path.Replace("\\", "/");
return !string.IsNullOrWhiteSpace(path);
}
private static string MapGitStatus(string code)
{
if (code.Contains("??")) return "Untracked";
if (code.Contains("A")) return "Added";
if (code.Contains("D")) return "Deleted";
if (code.Contains("R")) return "Renamed";
if (code.Contains("M")) return "Modified";
if (code.Contains("U")) return "Unmerged";
return "Changed";
}
// --- Commit ---
private async Task CommitGitChanges()
{
if (!EnsureGitRepository())
return;
var filesToCommit = _trackedRows.Where(x => x.Toggle.Value).ToList();
if (filesToCommit.Count == 0)
return;
_gitOperationInProgress = true;
_commitBtn.Enabled = false;
try
{
var allPaths = string.Join(" ", filesToCommit.Select(x => QuoteArg(x.FilePath)));
var (addOk, addOut, addErr) = await RunGitCommandAsync($"add -A -- {allPaths}");
if (!addOk)
{
Log.Error($"Git add failed: {addErr}");
ShowOperationNotification(
"Commit Error",
"Failed to stage files.",
"error",
Theme.Red
);
return;
}
string finalCommitMessage = _commitMessage.PlainText?.Trim();
if (string.IsNullOrWhiteSpace(finalCommitMessage))
{
if (filesToCommit.Count == 1)
{
// Extract just the file name for a clean message
string fileName = Path.GetFileName(filesToCommit[0].FilePath);
finalCommitMessage = $"Update {fileName}";
}
else
{
finalCommitMessage = $"Update {filesToCommit.Count} files";
}
}
var (commitOk, commitOut, commitErr) = await RunGitCommandAsync(
$"commit -m {QuoteArg(finalCommitMessage)}"
);
if (!commitOk)
{
Log.Warning(string.IsNullOrWhiteSpace(commitErr) ? commitOut : commitErr);
return;
}
ShowOperationNotification(
"Commit Created",
$"{filesToCommit.Count} file(s) committed",
"check_circle",
Theme.Green
);
_commitMessage.PlainText = "";
OnRefreshClicked();
}
catch (Exception e)
{
Log.Error($"Unexpected error during commit: {e.Message}");
}
finally
{
_gitOperationInProgress = false;
_commitBtn.Enabled = true;
}
}
// --- Remote actions ---
private void OnInitGitClicked()
{
if ( !IsGitAvailable() ) { Log.Error( "Git is not installed or not found in PATH." ); return; }
if ( IsGitRepository() ) { Log.Info( "Git repository already exists." ); return; }
var (ok, _, error) = RunGitCommand( "init -b main" );
if ( !ok ) { Log.Error( $"Failed to initialize Git repository: {error}" ); return; }
Log.Info( "Git repository initialized." );
CreateDefaultGitIgnoreIfMissing();
ShowOperationNotification( "Git Initialized", "Repository created for this project", "create_new_folder", Theme.Green );
OnRefreshClicked();
}
private void CreateDefaultGitIgnoreIfMissing()
{
try
{
string gitIgnorePath = Path.Combine( Project.Current.GetRootPath(), ".gitignore" );
if ( !File.Exists( gitIgnorePath ) )
{
string template =
@"# This file describes files and paths that should not be tracked by Git version control
# https://git-scm.com/docs/gitignore
# Auto-generated code editor files
.vs/*
.vscode/*
*.csproj
obj
bin
Properties/*
code/obj/*
code/Properties/*
# Auto-generated asset related files
.sbox/*
*.generated.*
*.*_c
!*.shader_c
*.los
*.vpk
*launchSettings.json
*.sln
*.slnx
*idea
# Exported / standalone games
Exports/";
File.WriteAllText( gitIgnorePath, template );
Log.Info( "Created default .gitignore template." );
}
}
catch ( Exception e )
{
Log.Warning( $"Failed to create default .gitignore: {e.Message}" );
}
}
private void OnConnectOriginClicked()
{
if ( !EnsureGitRepository() ) { Log.Warning( "Initialize a Git repository first." ); return; }
var url = _gitRemoteUrl?.Value?.Trim();
if ( string.IsNullOrWhiteSpace( url ) ) { Log.Warning( "Enter a remote repository URL first." ); return; }
var (hasOrigin, _, _) = RunGitCommand( "remote get-url origin" );
var args = hasOrigin ? $"remote set-url origin {QuoteArg( url )}" : $"remote add origin {QuoteArg( url )}";
var (ok, _, error) = RunGitCommand( args );
if ( !ok ) { Log.Error( $"Failed to configure remote: {error}" ); return; }
Log.Info( $"Remote origin set to: {url}" );
ShowOperationNotification( "Remote Set", url, "hub", Theme.Green );
}
private void TryPopulateGitRemoteUrlFromOrigin()
{
if ( _gitRemoteUrl == null || !IsGitAvailable() || !IsGitRepository() ) return;
var (ok, output, _) = RunGitCommand( "remote get-url origin" );
if ( ok && !string.IsNullOrWhiteSpace( output ) ) _gitRemoteUrl.Value = output.Trim();
}
private void OnPullClicked()
{
if ( !EnsureGitRepository() ) return;
Editor.Dialog.AskConfirm( () => _ = ExecutePullAsync(), "This will pull remote changes into your local project.\n\nContinue?", "Confirm Pull" );
}
private async Task ExecutePullAsync()
{
if ( _gitOperationInProgress ) { Log.Warning( "A Git operation is already in progress." ); return; }
bool success = false;
_gitOperationInProgress = true;
try
{
var (ok, output, error) = await RunGitCommandAsync( "pull" );
if ( !ok ) { Log.Warning( string.IsNullOrWhiteSpace( error ) ? output : error ); return; }
Log.Info( "Pull completed." );
ShowOperationNotification( "Pull Completed", "Remote changes applied", "download", Theme.Primary );
success = true;
}
finally { _gitOperationInProgress = false; }
if ( success )
{
OnRefreshClicked();
if ( _historyView.Visible ) RefreshHistoryUI();
}
}
private async Task ExecutePushAsync()
{
if ( _gitOperationInProgress ) { Log.Warning( "A Git operation is already in progress." ); return; }
bool success = false;
_gitOperationInProgress = true;
try
{
var (ok, output, error) = await RunGitCommandAsync( "push -u origin HEAD" );
if ( !ok ) { Log.Warning( string.IsNullOrWhiteSpace( error ) ? output : error ); return; }
Log.Info( "Push completed." );
ShowOperationNotification( "Push Completed", "Commits uploaded to remote", "upload", Theme.Green );
success = true;
}
finally { _gitOperationInProgress = false; }
if ( success )
{
_ = UpdateSyncStatusAsync();
if ( _historyView.Visible ) RefreshHistoryUI();
}
}
private void OnPushClicked()
{
if ( !EnsureGitRepository() ) return;
Editor.Dialog.AskConfirm( () => _ = ExecutePushAsync(), "This will push your local commits to the remote repository.\n\nContinue?", "Confirm Push" );
}
// --- Git helpers ---
private async Task UpdateSyncStatusAsync()
{
if ( !IsGitRepository() || _gitOperationInProgress ) return;
var (ok, output, _) = await RunGitCommandAsync( "rev-list --left-right --count HEAD...@{u}" );
if ( !ok || string.IsNullOrWhiteSpace( output ) )
{
_syncStatusLabel.Text = "<font color='#888'>Sync: Local only (no remote origin)</font>";
return;
}
var parts = output.Split( '\t' );
if ( parts.Length == 2 && int.TryParse( parts[0], out int ahead ) && int.TryParse( parts[1], out int behind ) )
{
if ( ahead == 0 && behind == 0 ) _syncStatusLabel.Text = "<font color='#77ff77'> Up to date with remote</font>";
else if ( ahead > 0 && behind == 0 ) _syncStatusLabel.Text = $"<font color='#77ccff'> {ahead} commit(s) ahead (Ready to Push)</font>";
else if ( ahead == 0 && behind > 0 ) _syncStatusLabel.Text = $"<font color='#ffcc77'> {behind} commit(s) behind (Need to Pull)</font>";
else _syncStatusLabel.Text = $"<font color='#ff7777'> {behind} behind / {ahead} ahead (Diverged)</font>";
}
}
private bool EnsureGitRepository() { return IsGitAvailable() && IsGitRepository(); }
private bool IsGitRepository() { return Directory.Exists( Path.Combine( Project.Current.GetRootPath(), ".git" ) ); }
private bool? _isGitAvailableCache = null;
private bool IsGitAvailable()
{
if (_isGitAvailableCache.HasValue)
return _isGitAvailableCache.Value;
var (ok, _, _) = RunGitCommandRaw("--version");
_isGitAvailableCache = ok;
return ok;
}
private string ReadGitFileAtRevision( string revision, string relativePath )
{
relativePath = relativePath.Replace( "\\", "/" );
var (ok, output, _) = RunGitCommand( $"show {revision}:{relativePath}" );
return ok ? output : "[No previous version in Git history]";
}
private (bool ok, string output, string error) RunGitCommand( string arguments ) { return RunGitCommandRaw( arguments ); }
private Task<(bool ok, string output, string error)> RunGitCommandAsync( string arguments ) { return Task.Run( () => RunGitCommandRaw( arguments ) ); }
private (bool ok, string output, string error) RunGitCommandRaw( string arguments )
{
try
{
var startInfo = new ProcessStartInfo( "git", arguments )
{
WorkingDirectory = Project.Current.GetRootPath(),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var proc = Process.Start( startInfo );
if ( proc == null ) return (false, "", "Failed to launch git process.");
var outTask = proc.StandardOutput.ReadToEndAsync();
var errTask = proc.StandardError.ReadToEndAsync();
proc.WaitForExit();
string output = outTask.GetAwaiter().GetResult();
string error = errTask.GetAwaiter().GetResult();
return (proc.ExitCode == 0, output.Trim(), error.Trim());
}
catch ( Exception e ) { return (false, "", e.Message); }
}
private static string QuoteArg( string input )
{
if ( input == null ) return "\"\"";
return $"\"{input.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" )}\"";
}
private string GetCommitWebUrl( string hash )
{
var url = _gitRemoteUrl?.Value?.Trim();
if ( string.IsNullOrWhiteSpace( url ) ) return null;
if ( url.EndsWith( ".git" ) ) url = url.Substring( 0, url.Length - 4 );
return $"{url}/commit/{hash}";
}
}