Editor/git/ForEachRef.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Sandbox.git.models;

namespace Sandbox.git;

/// <summary>
/// List branches and refs using git for-each-ref.
/// </summary>
public static class ForEachRef {
	const string OperationGetBranches = "getBranches";
	const string OperationGetBranchesDifferingFromUpstream = "getBranchesDifferingFromUpstream";

	/// <summary>Git exit code when not a git repository or similar fatal error.</summary>
	static readonly IReadOnlySet<int> SuccessExitCodesWithFatal = new HashSet<int> { 0, 128 };

	/// <summary>
	/// Get all the branches (local and remote).
	/// </summary>
	/// <param name="repository">The repository.</param>
	/// <param name="prefixes">Ref prefixes to list (e.g. refs/heads, refs/remotes). If null or empty, uses refs/heads and refs/remotes.</param>
	public static async Task<IReadOnlyList<models.Branch>> GetBranchesAsync(
		Repository repository,
		params string[]? prefixes
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		if ( prefixes == null || prefixes.Length == 0 )
			prefixes = new[] { "refs/heads", "refs/remotes" };

		// Format: fullName, shortName, upstreamShortName, sha, symRef (null-byte separated; one line per ref)
		var format = "%(refname)%00%(refname:short)%00%(upstream:short)%00%(objectname)%00%(symref)";
		var args = GetForEachRefArgs(format, prefixes);

		var result = await Core.GitAsync(
			args,
			repository.Path,
			OperationGetBranches,
			SuccessExitCodesWithFatal
		).ConfigureAwait(false);

		if ( result.ExitCode != 0 )
			return Array.Empty<models.Branch>();

		var branches = new List<models.Branch>();
		foreach ( var refEntry in ParseRefLines(result.Stdout, 5) ) {
			var fullName = refEntry[0];
			var shortName = refEntry[1];
			var upstreamShortName = refEntry[2];
			var sha = refEntry[3];
			var symRef = refEntry[4];

			// Exclude symbolic refs from the branch list
			if ( symRef.Length > 0 )
				continue;

			var tip = new BranchTip(sha);
			var type = fullName.StartsWith("refs/heads", StringComparison.Ordinal)
				? BranchType.Local
				: BranchType.Remote;
			var upstream = upstreamShortName.Length > 0 ? upstreamShortName : null;

			branches.Add(new models.Branch(shortName, upstream, tip, type, fullName));
		}

		return branches;
	}

	/// <summary>
	/// Gets all branches that differ from their upstream (ahead, behind, or both), excluding the current branch.
	/// Useful to narrow down branches that could potentially be fast-forwarded.
	/// </summary>
	public static async Task<IReadOnlyList<ITrackingBranch>> GetBranchesDifferingFromUpstreamAsync(
		Repository repository
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		// Format: fullName, sha, upstream, symref, head
		var format = "%(refname)%00%(objectname)%00%(upstream)%00%(symref)%00%(HEAD)";
		var prefixes = new[] { "refs/heads", "refs/remotes" };
		var args = GetForEachRefArgs(format, prefixes);

		var result = await Core.GitAsync(
			args,
			repository.Path,
			OperationGetBranchesDifferingFromUpstream,
			SuccessExitCodesWithFatal
		).ConfigureAwait(false);

		if ( result.ExitCode != 0 )
			return Array.Empty<ITrackingBranch>();

		var localBranches = new List<(string Ref, string Sha, string Upstream)>();
		var remoteBranchShas = new Dictionary<string, string>(StringComparer.Ordinal);

		foreach ( var refEntry in ParseRefLines(result.Stdout, 5) ) {
			var fullName = refEntry[0];
			var sha = refEntry[1];
			var upstream = refEntry[2];
			var symRef = refEntry[3];
			var head = refEntry[4];

			// Exclude symbolic refs and the current branch
			if ( symRef.Length > 0 || head == "*" )
				continue;

			if ( fullName.StartsWith("refs/heads", StringComparison.Ordinal) ) {
				if ( upstream.Length == 0 )
					continue;
				localBranches.Add((fullName, sha, upstream));
			} else {
				remoteBranchShas[fullName] = sha;
			}
		}

		var eligible = new List<ITrackingBranch>();
		foreach ( var branch in localBranches ) {
			if ( !remoteBranchShas.TryGetValue(branch.Upstream, out var remoteSha) )
				continue;
			if ( remoteSha == branch.Sha )
				continue;
			eligible.Add(new TrackingBranch(
				branch.Ref,
				branch.Sha,
				branch.Upstream,
				remoteSha
			));
		}

		return eligible;
	}

	/// <summary>Builds git arguments for for-each-ref. Exposed for testing.</summary>
	public static string[] GetForEachRefArgs(string format, string[] prefixes) {
		if ( format == null )
			throw new ArgumentNullException(nameof(format));
		if ( prefixes == null || prefixes.Length == 0 )
			throw new ArgumentException("At least one prefix is required.", nameof(prefixes));
		var list = new List<string> { "for-each-ref", $"--format={format}" };
		list.AddRange(prefixes);
		return list.ToArray();
	}

	/// <summary>
	/// Parses git for-each-ref output: one line per ref, fields separated by null byte.
	/// </summary>
	static IEnumerable<string[]> ParseRefLines(string stdout, int expectedFields) {
		if ( string.IsNullOrEmpty(stdout) )
			yield break;

		foreach ( var line in stdout.Split('\n') ) {
			var trimmed = line.TrimEnd('\r');
			if ( trimmed.Length == 0 )
				continue;

			var fields = trimmed.Split('\0');
			if ( fields.Length < expectedFields )
				continue;

			yield return fields;
		}
	}
}