Editor/git/Branch.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Sandbox.git.models;

namespace Sandbox.git;

/// <summary>
/// Branch-related git operations (create, list, rename, delete, query).
/// </summary>
public static class Branch {
	/// <summary>
	/// Create a new branch from the given start point.
	/// </summary>
	/// <param name="repository">The repository in which to create the new branch.</param>
	/// <param name="name">The name of the new branch.</param>
	/// <param name="startPoint">A committish string that the new branch should be based on, or null if the branch should be created from the current HEAD.</param>
	/// <param name="noTrack">If true, do not set up tracking (e.g. when branching from a remote branch).</param>
	public static async Task CreateBranchAsync(
		Repository repository,
		string name,
		string? startPoint,
		bool noTrack = false) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( string.IsNullOrEmpty(name) )
			throw new ArgumentException("Branch name is required.", nameof(name));

		var args = GetCreateBranchArgs(name, startPoint, noTrack);
		await Core.GitAsync(args, repository.Path, "createBranch").ConfigureAwait(false);
	}

	/// <summary>
	/// Gets the short names of all local branches.
	/// </summary>
	public static async Task<string[]> GetBranchNamesAsync(Repository repository) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		var result = await Core.GitAsync(
			GetBranchNamesArgs(),
			repository.Path,
			"getBranchNames").ConfigureAwait(false);

		return ParseBranchLines(result.Stdout)
			.Select(line => line.Trim())
			.Where(s => s.Length > 0)
			.ToArray();
	}

	/// <summary>
	/// Rename the given branch to a new name.
	/// </summary>
	/// <param name="repository">The repository.</param>
	/// <param name="branch">The branch to rename.</param>
	/// <param name="newName">The new name.</param>
	/// <param name="force">If true, use -M to force rename (e.g. case-only renames on case-insensitive filesystems).</param>
	public static async Task RenameBranchAsync(
		Repository repository,
		models.Branch branch,
		string newName,
		bool? force = null) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( branch == null )
			throw new ArgumentNullException(nameof(branch));
		if ( string.IsNullOrEmpty(newName) )
			throw new ArgumentException("New branch name is required.", nameof(newName));

		try {
			await Core.GitAsync(
				GetRenameBranchArgs(branch.NameWithoutRemote, newName, force),
				repository.Path,
				"renameBranch").ConfigureAwait(false);
		} catch ( GitException ex ) {
			// If we failed and the branch name only differs by case, retry with -M (see Desktop #21320).
			if ( force != null )
				throw;

			if ( !IsBranchAlreadyExistsError(ex.Result) )
				throw;

			var m = Regex.Match(ex.Result.Stderr, @"fatal: a branch named '(.+?)' already exists");
			if ( !m.Success || !string.Equals(m.Groups[1].Value, newName, StringComparison.OrdinalIgnoreCase) )
				throw;

			var names = await GetBranchNamesAsync(repository).ConfigureAwait(false);
			if ( names.Contains(newName) )
				throw;

			await RenameBranchAsync(repository, branch, newName, true).ConfigureAwait(false);
		}
	}

	/// <summary>
	/// Delete the branch locally (force delete with -D).
	/// </summary>
	public static async Task DeleteLocalBranchAsync(Repository repository, string branchName) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( string.IsNullOrEmpty(branchName) )
			throw new ArgumentException("Branch name is required.", nameof(branchName));

		await Core.GitAsync(
			GetDeleteLocalBranchArgs(branchName),
			repository.Path,
			"deleteLocalBranch").ConfigureAwait(false);
	}

	/// <summary>
	/// Deletes a remote branch (push :ref to remote). If the remote ref was already deleted, removes the local remote-tracking ref.
	/// </summary>
	/// <param name="repository">The repository.</param>
	/// <param name="remote">The remote (e.g. origin).</param>
	/// <param name="remoteBranchName">The name of the branch on the remote.</param>
	public static async Task DeleteRemoteBranchAsync(
		Repository repository,
		IRemote remote,
		string remoteBranchName) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( remote == null )
			throw new ArgumentNullException(nameof(remote));
		if ( string.IsNullOrEmpty(remoteBranchName) )
			throw new ArgumentException("Remote branch name is required.", nameof(remoteBranchName));

		var args = GetDeleteRemoteBranchArgs(remote.Name, remoteBranchName);
		var result = await Core.GitAsync(
			args,
			repository.Path,
			"deleteRemoteBranch",
			successExitCodes: new HashSet<int> { 0, 1 }).ConfigureAwait(false);

		// If push failed (e.g. ref already deleted on remote), remove our local remote-tracking ref.
		if ( result.ExitCode != 0 ) {
			var refName = $"refs/remotes/{remote.Name}/{remoteBranchName}";
			await DeleteRefAsync(repository, refName).ConfigureAwait(false);
		}
	}

	/// <summary>
	/// Finds branches whose tip equals the given committish (sha, HEAD, etc.).
	/// </summary>
	/// <returns>List of branch short names, or null if the committish could not be resolved or was malformed.</returns>
	public static async Task<string[]?> GetBranchesPointedAtAsync(Repository repository, string commitish) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( string.IsNullOrEmpty(commitish) )
			throw new ArgumentException("Committish is required.", nameof(commitish));

		var result = await Core.GitAsync(
			GetBranchesPointedAtArgs(commitish),
			repository.Path,
			"branchPointedAt",
			successExitCodes: new HashSet<int> { 0, 1, 129 }).ConfigureAwait(false);

		if ( result.ExitCode == 1 || result.ExitCode == 129 )
			return null;

		var lines = result.Stdout.Split('\n');
		return lines.Length > 0 && string.IsNullOrEmpty(lines[lines.Length - 1])
			? lines.Take(lines.Length - 1).ToArray()
			: lines;
	}

	/// <summary>
	/// Gets all branches that have been merged into the given branch.
	/// </summary>
	/// <param name="repository">The repository.</param>
	/// <param name="branchName">The base branch name (e.g. main).</param>
	/// <returns>Map of branch canonical ref to its tip sha (excluding the base branch itself).</returns>
	public static async Task<Dictionary<string, string>> GetMergedBranchesAsync(
		Repository repository,
		string branchName) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( string.IsNullOrEmpty(branchName) )
			throw new ArgumentException("Branch name is required.", nameof(branchName));

		var canonicalBranchRef = FormatAsLocalRef(branchName);
		var result = await Core.GitAsync(
			GetMergedBranchesArgs(branchName),
			repository.Path,
			"mergedBranches").ConfigureAwait(false);

		var merged = new Dictionary<string, string>(StringComparer.Ordinal);
		foreach ( var line in ParseBranchLines(result.Stdout) ) {
			var trimmed = line.Trim();
			if ( trimmed.Length == 0 )
				continue;

			var firstSpace = trimmed.IndexOf(' ');
			if ( firstSpace <= 0 )
				continue;

			var sha = trimmed.Substring(0, firstSpace);
			var canonicalRef = trimmed.Substring(firstSpace + 1).Trim();
			if ( canonicalRef == canonicalBranchRef )
				continue;

			merged[canonicalRef] = sha;
		}

		return merged;
	}

	// --- Helpers ---

	/// <summary>
	/// Canonical local branch ref (e.g. refs/heads/main).
	/// </summary>
	public static string FormatAsLocalRef(string branchName) {
		if ( string.IsNullOrEmpty(branchName) )
			throw new ArgumentException("Branch name is required.", nameof(branchName));
		return "refs/heads/" + branchName;
	}

	static async Task DeleteRefAsync(Repository repository, string refName) {
		await Core.GitAsync(
			GetDeleteRefArgs(refName),
			repository.Path,
			"deleteRef",
			successExitCodes: new HashSet<int> { 0, 1 }).ConfigureAwait(false);
	}

	// --- Args (exposed for testing) ---

	/// <summary>Builds git arguments for creating a branch. Exposed for testing.</summary>
	public static string[] GetCreateBranchArgs(string name, string? startPoint, bool noTrack = false) {
		if ( string.IsNullOrEmpty(name) )
			throw new ArgumentException("Branch name is required.", nameof(name));
		var args = startPoint != null
			? new[] { "branch", name, startPoint }
			: new[] { "branch", name };
		if ( noTrack )
			args = args.Concat(new[] { "--no-track" }).ToArray();
		return args;
	}

	/// <summary>Builds git arguments for listing branch names. Exposed for testing.</summary>
	public static string[] GetBranchNamesArgs() {
		return new[] { "branch", "--format=%(refname:short)" };
	}

	/// <summary>Builds git arguments for renaming a branch. Exposed for testing.</summary>
	public static string[] GetRenameBranchArgs(string nameWithoutRemote, string newName, bool? force = null) {
		if ( string.IsNullOrEmpty(nameWithoutRemote) )
			throw new ArgumentException("Branch name is required.", nameof(nameWithoutRemote));
		if ( string.IsNullOrEmpty(newName) )
			throw new ArgumentException("New branch name is required.", nameof(newName));
		return new[] { "branch", force == true ? "-M" : "-m", nameWithoutRemote, newName };
	}

	/// <summary>Builds git arguments for deleting a local branch. Exposed for testing.</summary>
	public static string[] GetDeleteLocalBranchArgs(string branchName) {
		if ( string.IsNullOrEmpty(branchName) )
			throw new ArgumentException("Branch name is required.", nameof(branchName));
		return new[] { "branch", "-D", branchName };
	}

	/// <summary>Builds git arguments for deleting a remote branch (push :ref). Exposed for testing.</summary>
	public static string[] GetDeleteRemoteBranchArgs(string remoteName, string remoteBranchName) {
		if ( string.IsNullOrEmpty(remoteName) )
			throw new ArgumentException("Remote name is required.", nameof(remoteName));
		if ( string.IsNullOrEmpty(remoteBranchName) )
			throw new ArgumentException("Remote branch name is required.", nameof(remoteBranchName));
		return new[] { "push", remoteName, $":{remoteBranchName}" };
	}

	/// <summary>Builds git arguments for listing branches pointed at a committish. Exposed for testing.</summary>
	public static string[] GetBranchesPointedAtArgs(string commitish) {
		if ( string.IsNullOrEmpty(commitish) )
			throw new ArgumentException("Committish is required.", nameof(commitish));
		return new[] { "branch", $"--points-at={commitish}", "--format=%(refname:short)" };
	}

	/// <summary>Builds git arguments for listing merged branches. Exposed for testing.</summary>
	public static string[] GetMergedBranchesArgs(string branchName) {
		if ( string.IsNullOrEmpty(branchName) )
			throw new ArgumentException("Branch name is required.", nameof(branchName));
		return new[] { "branch", "--format=%(objectname) %(refname)", "--merged", branchName };
	}

	/// <summary>Builds git arguments for deleting a ref. Exposed for testing.</summary>
	public static string[] GetDeleteRefArgs(string refName) {
		if ( string.IsNullOrEmpty(refName) )
			throw new ArgumentException("Ref name is required.", nameof(refName));
		return new[] { "update-ref", "-d", refName };
	}

	static bool IsBranchAlreadyExistsError(GitResult result) {
		return result.ExitCode != 0
		       && (result.Stderr?.Contains("a branch named ", StringComparison.OrdinalIgnoreCase) == true
		           && result.Stderr?.Contains(" already exists", StringComparison.OrdinalIgnoreCase) == true);
	}

	static IEnumerable<string> ParseBranchLines(string stdout) {
		if ( string.IsNullOrEmpty(stdout) )
			yield break;
		foreach ( var line in stdout.Split('\n') )
			yield return line;
	}
}