Editor/git/Status.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Sandbox.git.models;
namespace Sandbox.git;
/// <summary>
/// Repository status (branch info and ahead/behind from git status --porcelain=2 --branch).
/// </summary>
public static class Status {
const string OperationGetStatus = "getStatus";
const string OperationGetFullStatus = "getFullStatus";
static readonly IReadOnlySet<int> SuccessExitCodesStatus = new HashSet<int> { 0, 128 };
// Conflict status codes (index + work tree) from Desktop status-parser
static readonly HashSet<string> ConflictStatusCodes = new HashSet<string>(StringComparer.Ordinal) {
"DD",
"AU",
"UD",
"UA",
"DU",
"AA",
"UU"
};
// branch.oid (initial) or branch.oid <sha>
static readonly Regex BranchOidRe = new Regex(@"^#\s+branch\.oid\s+(.+)$", RegexOptions.Compiled);
// branch.head <name> or (detached)
static readonly Regex BranchHeadRe = new Regex(@"^#\s+branch\.head\s+(.+)$", RegexOptions.Compiled);
// branch.upstream <name>
static readonly Regex BranchUpstreamRe = new Regex(@"^#\s+branch\.upstream\s+(.+)$", RegexOptions.Compiled);
// branch.ab +<ahead> -<behind>
static readonly Regex BranchAbRe = new Regex(@"^#\s+branch\.ab\s+\+(\d+)\s+-(\d+)$", RegexOptions.Compiled);
/// <summary>
/// Load status for the repository. Returns branch name, tip SHA, upstream, and ahead/behind.
/// Returns null if the path is not a repository (exit 128).
/// </summary>
public static async Task<StatusResult?> GetStatusAsync(Repository repository) {
if ( repository == null )
throw new ArgumentNullException(nameof(repository));
var result = await Core.GitAsync(
GetStatusArgs(),
repository.Path,
OperationGetStatus,
SuccessExitCodesStatus
).ConfigureAwait(false);
if ( result.ExitCode == 128 )
return null;
return ParseStatusOutput(result.Stdout);
}
/// <summary>
/// Load status using repository path (e.g. from RevParse). Returns null if not a repo.
/// </summary>
public static async Task<StatusResult?> GetStatusAsync(string path) {
if ( string.IsNullOrEmpty(path) )
return null;
var result = await Core.GitAsync(
GetStatusArgs(),
path,
OperationGetStatus,
SuccessExitCodesStatus
).ConfigureAwait(false);
if ( result.ExitCode == 128 )
return null;
return ParseStatusOutput(result.Stdout);
}
/// <summary>
/// Load full status (branch info + working directory file changes). Uses porcelain=2 -z and includes untracked files.
/// Returns null if the path is not a repository (exit 128).
/// </summary>
public static async Task<FullStatusResult?> GetFullStatusAsync(string path) {
if ( string.IsNullOrEmpty(path) )
return null;
var result = await Core.GitAsync(
GetFullStatusArgs(),
path,
OperationGetFullStatus,
SuccessExitCodesStatus
).ConfigureAwait(false);
if ( result.ExitCode == 128 )
return null;
return ParseFullStatusOutput(result.Stdout);
}
/// <summary>Builds git arguments for status (branch + porcelain=2). Exposed for testing.</summary>
public static string[] GetStatusArgs() {
return new[] { "status", "--branch", "--porcelain=2" };
}
/// <summary>Builds git arguments for full status (with untracked, -z). Exposed for testing.</summary>
public static string[] GetFullStatusArgs() {
return new[] { "--no-optional-locks", "status", "--untracked-files=all", "--branch", "--porcelain=2", "-z" };
}
static FullStatusResult ParseFullStatusOutput(string stdout) {
// Strip BOM if present (can cause header lines to be missed and branch to show as detached)
if ( stdout.Length > 0 && stdout[0] == '\uFEFF' )
stdout = stdout.Substring(1);
// In -z mode, NUL terminates each LINE (record), not each field. Split once to get records (Desktop: splitBuffer(output, '\0')).
var records = stdout.Split('\0');
string? currentBranch = null;
string? currentTip = null;
string? currentUpstream = null;
IAheadBehind? aheadBehind = null;
var files = new List<GitWorkingDirectoryFileChange>();
for ( var i = 0; i < records.Length; i++ ) {
var record = records[i];
if ( string.IsNullOrEmpty(record) )
continue;
// Header lines: "# branch.oid xxx", "# branch.head main", etc. (Desktop: field.startsWith('# ') && field.length > 2)
if ( record.StartsWith("# ", StringComparison.Ordinal) && record.Length > 2 ) {
var oidMatch = BranchOidRe.Match(record);
if ( oidMatch.Success ) {
var oid = oidMatch.Groups[1].Value.Trim();
if ( oid != "(initial)" )
currentTip = oid;
continue;
}
var headMatch = BranchHeadRe.Match(record);
if ( headMatch.Success ) {
var head = headMatch.Groups[1].Value.Trim();
if ( head != "(detached)" )
currentBranch = head;
continue;
}
var upstreamMatch = BranchUpstreamRe.Match(record);
if ( upstreamMatch.Success ) {
currentUpstream = upstreamMatch.Groups[1].Value.Trim();
continue;
}
var abMatch = BranchAbRe.Match(record);
if ( abMatch.Success
&& int.TryParse(abMatch.Groups[1].Value, out var ahead)
&& int.TryParse(abMatch.Groups[2].Value, out var behind) ) {
aheadBehind = new AheadBehind(ahead, behind);
}
continue;
}
var entryKind = record.Length > 0 ? record.Substring(0, 1) : "";
if ( entryKind == "1" ) {
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path> — space-separated, path is rest after 8th field (Desktop: parseChangedEntry)
var parsed = ParseType1Record(record);
if ( parsed != null && !ShouldSkipEntry(parsed.Value.XY) )
files.Add(
new GitWorkingDirectoryFileChange(parsed.Value.Path, MapStatusCode(parsed.Value.XY), null));
continue;
}
if ( entryKind == "2" ) {
// 2 <XY> ... <path> then next record is origPath (Desktop: parsedRenamedOrCopiedEntry(field, tokens[++i]))
var parsed = ParseType2Record(record);
if ( parsed != null && i + 1 < records.Length ) {
var origPath = records[++i].TrimEnd('\r');
if ( !ShouldSkipEntry(parsed.Value.XY) )
files.Add(new GitWorkingDirectoryFileChange(parsed.Value.Path, MapStatusCode(parsed.Value.XY),
origPath));
}
continue;
}
if ( entryKind == "?" ) {
// ? <path> — path is from position 2 (Desktop: parseUntrackedEntry, path = field.substring(2))
var path = record.Length > 2 ? record.Substring(2).TrimEnd('\r') : "";
if ( path.Length > 0 )
files.Add(new GitWorkingDirectoryFileChange(path, FileChangeKind.Untracked, null));
continue;
}
if ( entryKind == "!" ) {
// Ignored — skip (Desktop: "we don't care about these for now")
}
}
var workingDirectory = new GitWorkingDirectoryStatus(files);
return new FullStatusResult(
currentBranch,
currentTip,
currentUpstream,
aheadBehind,
workingDirectory
);
}
/// <summary>Parsed type-1 (ordinary) entry: 1 XY sub mH mI mW hH hI path (space-separated, path may contain spaces).</summary>
static (string XY, string Path)? ParseType1Record(string record) {
// Desktop: /^1 ([MADRCUTX?!.]{2}) (N\.\.\.|S[C.][M.][U.]) (\d+) (\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([\s\S]*?)$/
var parts = record.Split(new[] { ' ' }, 9,
StringSplitOptions.None); // at most 9 parts, last is path (may contain spaces)
if ( parts.Length < 9 )
return null;
var xy = parts[1].Length >= 2 ? parts[1] : parts[1].PadRight(2, ' ');
return (xy, parts[8]);
}
/// <summary>Parsed type-2 (rename/copy) entry: 2 XY sub mH mI mW hH hI R100 path (origPath is next record).</summary>
static (string XY, string Path)? ParseType2Record(string record) {
var parts = record.Split(new[] { ' ' }, 10, StringSplitOptions.None); // 9 fields + path
if ( parts.Length < 10 )
return null;
var xy = parts[1].Length >= 2 ? parts[1] : parts[1].PadRight(2, ' ');
return (xy, parts[9]);
}
/// <summary>Desktop: when added in index but deleted in work tree, skip (file won't be in commit).</summary>
static bool ShouldSkipEntry(string xy) {
if ( xy.Length < 2 ) return false;
var x = xy[0];
var y = xy.Length > 1 ? xy[1] : ' ';
return x == 'A' && y == 'D';
}
/// <summary>Map two-char XY (index + work tree) to FileChangeKind. Mirrors Desktop mapStatus / convertToAppStatus.</summary>
static FileChangeKind MapStatusCode(string xy) {
if ( string.IsNullOrEmpty(xy) || xy.Length < 2 )
return FileChangeKind.Modified;
var normalized = xy.PadRight(2, ' ');
if ( ConflictStatusCodes.Contains(normalized) )
return FileChangeKind.Conflicted;
var y = normalized[1]; // work tree
if ( y == '?' )
return FileChangeKind.Untracked;
var x = normalized[0]; // index
if ( x == 'R' || y == 'R' )
return FileChangeKind.Renamed;
if ( x == 'C' || y == 'C' )
return FileChangeKind.Copied;
if ( x == 'A' || y == 'A' )
return FileChangeKind.New;
if ( x == 'M' || y == 'M' )
return FileChangeKind.Modified;
if ( x == 'D' || y == 'D' )
return FileChangeKind.Deleted;
return FileChangeKind.Modified;
}
static StatusResult ParseStatusOutput(string stdout) {
string? currentBranch = null;
string? currentTip = null;
string? currentUpstream = null;
IAheadBehind? aheadBehind = null;
foreach ( var line in ReadHeaderLines(stdout) ) {
var oidMatch = BranchOidRe.Match(line);
if ( oidMatch.Success ) {
var oid = oidMatch.Groups[1].Value.Trim();
if ( oid != "(initial)" )
currentTip = oid;
continue;
}
var headMatch = BranchHeadRe.Match(line);
if ( headMatch.Success ) {
var head = headMatch.Groups[1].Value.Trim();
if ( head != "(detached)" )
currentBranch = head;
continue;
}
var upstreamMatch = BranchUpstreamRe.Match(line);
if ( upstreamMatch.Success ) {
currentUpstream = upstreamMatch.Groups[1].Value.Trim();
continue;
}
var abMatch = BranchAbRe.Match(line);
if ( abMatch.Success
&& int.TryParse(abMatch.Groups[1].Value, out var ahead)
&& int.TryParse(abMatch.Groups[2].Value, out var behind) ) {
aheadBehind = new AheadBehind(ahead, behind);
}
}
return new StatusResult(currentBranch, currentTip, currentUpstream, aheadBehind);
}
static IEnumerable<string> ReadHeaderLines(string stdout) {
if ( string.IsNullOrEmpty(stdout) )
yield break;
foreach ( var line in stdout.Split('\n') ) {
var trimmed = line.TrimEnd('\r');
if ( !trimmed.StartsWith("# ", StringComparison.Ordinal) )
break;
yield return trimmed;
}
}
}