Editor/git/Core.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Sandbox.Diagnostics;
namespace Sandbox.git;
public readonly struct GitResult {
public int ExitCode { get; }
public string Stdout { get; }
public string Stderr { get; }
public GitResult(int exitCode, string stdout, string stderr) {
ExitCode = exitCode;
Stdout = stdout ?? string.Empty;
Stderr = stderr ?? string.Empty;
}
}
public static class Core {
private static readonly Logger Logger = new Logger("SandGit[Git]");
private static string _gitExe;
/// <summary>Returns the git executable path currently in use (for display in settings). Resolves and caches if needed.</summary>
public static string GetCurrentGitPath() => GetGitExe();
/// <summary>Sets a user override for the git executable path. Use null or empty to clear and fall back to detection.</summary>
public static void SetGitPathOverride(string path) {
_gitExe = null;
try {
var file = GetGitOverrideFile();
var dir = Path.GetDirectoryName(file);
if ( !string.IsNullOrEmpty(dir) )
Directory.CreateDirectory(dir);
File.WriteAllText(file, path ?? string.Empty);
} catch {
// Intentionally ignore.
}
}
/// <summary>
/// Runs a git operation with the given args, repository path, and operation name.
/// </summary>
public static void Git(string[] args, string path, string operationName) {
_ = GitAsync(args, path, operationName).GetAwaiter().GetResult();
}
/// <summary>
/// Runs a git operation asynchronously. Non-blocking.
/// </summary>
/// <param name="args">Git arguments (e.g. ["rev-parse", "--is-bare-repository"]).</param>
/// <param name="path">Working directory (repository path).</param>
/// <param name="operationName">Name used for logging.</param>
/// <param name="successExitCodes">If set, exit codes in this set are treated as success; otherwise only 0 is success and other codes throw.</param>
/// <param name="stdin">Optional input to send on stdin (e.g. null-separated paths for checkout-index --stdin -z).</param>
/// <returns>Exit code, stdout, and stderr.</returns>
public static async Task<GitResult> GitAsync(
string[] args,
string path,
string operationName,
IReadOnlySet<int> successExitCodes = null,
string stdin = null
) {
using var process = new Process();
process.StartInfo.FileName = GetGitExe();
process.StartInfo.Arguments = BuildArguments(args);
process.StartInfo.WorkingDirectory = path;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.StandardOutputEncoding = Encoding.UTF8;
process.StartInfo.StandardErrorEncoding = Encoding.UTF8;
if ( stdin != null ) {
process.StartInfo.RedirectStandardInput = true;
process.StartInfo.StandardInputEncoding = Encoding.UTF8;
}
var gitCommand = path + ": " + process.StartInfo.FileName + " " + string.Join(" ", args);
Logger.Trace(gitCommand);
process.Start();
if ( stdin != null ) {
process.StandardInput.Write(stdin);
process.StandardInput.Close();
}
var outTask = process.StandardOutput.ReadToEndAsync();
var errTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync().ConfigureAwait(false);
var stdout = await outTask.ConfigureAwait(false);
var stderr = await errTask.ConfigureAwait(false);
Logger.Trace(gitCommand + " - " + process.ExitCode + " - " + stdout + " - " + stderr);
var result = new GitResult(process.ExitCode, stdout, stderr);
if ( successExitCodes != null && !successExitCodes.Contains(result.ExitCode) ) {
throw new GitException(result, operationName);
}
if ( successExitCodes == null && result.ExitCode != 0 ) {
throw new GitException(result, operationName);
}
return result;
}
static string GetGitExe() {
// 0) User override from settings
var ov = GetOverridePath();
if ( !string.IsNullOrWhiteSpace(ov) )
return _gitExe = ov;
if ( _gitExe != null )
return _gitExe;
// 1) Saved path from previous successful launch
var saved = GetSavedGitPath();
if ( !string.IsNullOrEmpty(saved) && TryGitAtPath(saved) )
return _gitExe = SaveAndReturn(saved);
// 2) PATH
if ( TryGitAtPath("git") )
return _gitExe = "git";
// 3) Common Windows installation paths (one at a time)
var commonPaths = new[] {
@"C:\Program Files\Git\cmd\git.exe", @"C:\Program Files\Git\bin\git.exe",
@"C:\Program Files (x86)\Git\cmd\git.exe", @"C:\Program Files (x86)\Git\bin\git.exe", Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
@"Programs\Git\cmd\git.exe"),
};
foreach ( var candidate in commonPaths ) {
if ( !File.Exists(candidate) )
continue;
if ( TryGitAtPath(candidate) )
return _gitExe = SaveAndReturn(candidate);
}
// 4) where.exe to discover git
var wherePath = TryWhereGit();
if ( !string.IsNullOrEmpty(wherePath) && TryGitAtPath(wherePath) )
return _gitExe = SaveAndReturn(wherePath);
// 5) Last resort
return _gitExe = "git";
}
/// <summary>Runs git --version at the given path; returns true if exit code 0.</summary>
static bool TryGitAtPath(string exePath) {
try {
using var p = new Process();
p.StartInfo.FileName = exePath;
p.StartInfo.Arguments = "--version";
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardOutput = true;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.CreateNoWindow = true;
p.Start();
p.WaitForExit(5000);
return p.ExitCode == 0;
} catch {
// Intentionally ignore; try next candidate.
return false;
}
}
static string SaveAndReturn(string path) {
if ( path != null && (path.Contains(Path.DirectorySeparatorChar) || path.Contains('/')) )
SaveGitPath(path);
return path;
}
static string GetSavedGitPath() {
try {
var file = GetGitPathCacheFile();
if ( File.Exists(file) )
return File.ReadAllText(file).Trim();
} catch {
// Intentionally ignore; fall back to detection.
}
return null;
}
static void SaveGitPath(string path) {
try {
var file = GetGitPathCacheFile();
var dir = Path.GetDirectoryName(file);
if ( !string.IsNullOrEmpty(dir) )
Directory.CreateDirectory(dir);
File.WriteAllText(file, path);
} catch {
// Intentionally ignore; in-memory cache still used.
}
}
static string GetGitPathCacheFile() =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SandGit",
"git-exe.txt");
static string GetGitOverrideFile() =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SandGit",
"git-override.txt");
static string GetOverridePath() {
try {
var file = GetGitOverrideFile();
if ( File.Exists(file) ) {
var s = File.ReadAllText(file).Trim();
if ( !string.IsNullOrEmpty(s) )
return s;
}
} catch {
// Intentionally ignore.
}
return null;
}
static string TryWhereGit() {
try {
var whereExe = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "where.exe");
if ( !File.Exists(whereExe) )
return null;
using var p = new Process();
p.StartInfo.FileName = whereExe;
p.StartInfo.Arguments = "git";
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardOutput = true;
p.StartInfo.CreateNoWindow = true;
p.Start();
var line = p.StandardOutput.ReadLine()?.Trim();
p.WaitForExit(5000);
if ( !string.IsNullOrEmpty(line) && File.Exists(line) )
return line;
} catch {
// Intentionally ignore; fall back to "git".
}
return null;
}
static string BuildArguments(string[] args) {
if ( args == null || args.Length == 0 ) return string.Empty;
var sb = new StringBuilder();
foreach ( var a in args ) {
if ( sb.Length > 0 ) sb.Append(' ');
if ( a.Contains(" ") ) {
sb.Append('"').Append(a.Replace("\"", "\\\"")).Append('"');
} else {
sb.Append(a);
}
}
return sb.ToString();
}
}
public class GitException : Exception {
public GitResult Result { get; }
public GitException(GitResult result, string operationName)
: base($"Git {operationName} failed with exit code {result.ExitCode}. stderr: {result.Stderr}") {
Result = result;
}
}