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

namespace Sandbox.git;

/// <summary>
/// Commit operations: regular and merge commits.
/// Loosely mirrors Desktop's commit.ts while fitting SandGit's Core wrapper.
/// </summary>
public static class Commit {
	const string OperationCreateCommit = "createCommit";
	const string OperationCreateMergeCommit = "createMergeCommit";
	const string OperationUnstageAll = "unstageAll";
	const string OperationStageFiles = "stageFiles";
	const string OperationGetHeadSha = "getHeadSha";
	const string OperationStageManualResolution = "stageManualResolution";

	static readonly Logger Logger = new Logger("SandGit[Commit]");

	// ─── Public API ─────────────────────────────────────────────────────────

	/// <summary>
	/// Creates a commit for the given working directory changes.
	///
	/// Clears the index, stages the provided <paramref name="files"/>, and then
	/// runs <c>git commit -F -</c> with the supplied message.
	/// </summary>
	/// <param name="repository">The repository to commit in.</param>
	/// <param name="message">The commit message text.</param>
	/// <param name="files">
	/// The working directory changes to include. If null or empty, stages all
	/// changes in the working directory (equivalent to <c>git add -A</c>).
	/// </param>
	/// <param name="amend">If true, passes <c>--amend</c> to git commit.</param>
	/// <param name="noVerify">If true, passes <c>--no-verify</c> to git commit.</param>
	/// <returns>The SHA of the created (or amended) commit.</returns>
	public static async Task<string> CreateCommitAsync(
		Repository repository,
		string message,
		IReadOnlyList<GitWorkingDirectoryFileChange>? files,
		bool amend = false,
		bool noVerify = false
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( string.IsNullOrWhiteSpace(message) )
			throw new ArgumentException("Commit message is required.", nameof(message));

		await UnstageAllAsync(repository).ConfigureAwait(false);
		await StageFilesAsync(repository, files).ConfigureAwait(false);

		var args = new List<string> { "commit", "-F", "-" };

		if ( amend )
			args.Add("--amend");

		if ( noVerify )
			args.Add("--no-verify");

		_ = await Core.GitAsync(
			args.ToArray(),
			repository.Path,
			OperationCreateCommit,
			stdin: message
		).ConfigureAwait(false);

		return await GetHeadShaAsync(repository).ConfigureAwait(false);
	}

	/// <summary>
	/// Creates a commit to finish an in-progress merge.
	///
	/// Applies any manual conflict resolutions, stages remaining <paramref name="files"/>,
	/// and then runs <c>git commit --no-edit --cleanup=strip</c>.
	/// </summary>
	/// <param name="repository">The repository to commit in.</param>
	/// <param name="files">Files participating in the merge.</param>
	/// <param name="manualResolutions">
	/// Optional map from file path to manual resolution side (ours/theirs).
	/// Paths must be relative to the repository root (same as status paths).
	/// </param>
	/// <returns>The SHA of the created merge commit.</returns>
	public static async Task<string> CreateMergeCommitAsync(
		Repository repository,
		IReadOnlyList<GitWorkingDirectoryFileChange>? files,
		IReadOnlyDictionary<string, ManualConflictResolution>? manualResolutions = null
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		var allFiles = files ?? Array.Empty<GitWorkingDirectoryFileChange>();
		var resolutions = manualResolutions ?? new Dictionary<string, ManualConflictResolution>();

		if ( resolutions.Count > 0 && allFiles.Count > 0 ) {
			foreach ( var kvp in resolutions ) {
				var path = kvp.Key;
				var resolution = kvp.Value;

				var file = allFiles.FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.Ordinal));

				if ( file == null ) {
					Logger.Trace(
						$"[Commit] Manual resolution requested for '{path}' but no matching file was found.");
					continue;
				}

				await StageManualConflictResolutionAsync(repository, file, resolution).ConfigureAwait(false);
			}
		}

		var otherFiles = allFiles
			.Where(f => !resolutions.ContainsKey(f.Path))
			.ToList();

		if ( otherFiles.Count > 0 )
			await StageFilesAsync(repository, otherFiles).ConfigureAwait(false);

		var args = new[] { "commit", "--no-edit", "--cleanup=strip" };

		_ = await Core.GitAsync(
			args,
			repository.Path,
			OperationCreateMergeCommit
		).ConfigureAwait(false);

		return await GetHeadShaAsync(repository).ConfigureAwait(false);
	}

	// ─── Private helpers ────────────────────────────────────────────────────

	static async Task UnstageAllAsync(Repository repository) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		// Mirrors Desktop's intent of clearing the index before staging the
		// files we care about. Equivalent to: git reset HEAD -- .
		var args = new[] { "reset", "HEAD", "--", "." };
		_ = await Core.GitAsync(
			args,
			repository.Path,
			OperationUnstageAll
		).ConfigureAwait(false);
	}

	static async Task StageFilesAsync(
		Repository repository,
		IReadOnlyList<GitWorkingDirectoryFileChange>? files
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		// If no specific files are provided, stage everything (commit all).
		if ( files == null || files.Count == 0 ) {
			var addAllArgs = new[] { "add", "-A" };
			_ = await Core.GitAsync(
				addAllArgs,
				repository.Path,
				OperationStageFiles
			).ConfigureAwait(false);
			return;
		}

		var paths = files
			.Select(f => f.Path)
			.Where(p => !string.IsNullOrEmpty(p))
			.Distinct(StringComparer.Ordinal)
			.ToList();

		if ( paths.Count == 0 )
			return;

		var args = new List<string> { "add", "--" };
		args.AddRange(paths);

		_ = await Core.GitAsync(
			args.ToArray(),
			repository.Path,
			OperationStageFiles
		).ConfigureAwait(false);
	}

	static async Task StageManualConflictResolutionAsync(
		Repository repository,
		GitWorkingDirectoryFileChange file,
		ManualConflictResolution resolution
	) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));
		if ( file == null )
			throw new ArgumentNullException(nameof(file));
		if ( string.IsNullOrEmpty(file.Path) )
			throw new ArgumentException("File path is required.", nameof(file));

		// Reuse Checkout's semantics for applying "ours" or "theirs" and then stage the result.
		await Checkout.CheckoutConflictedFileAsync(repository, file, resolution).ConfigureAwait(false);

		var args = new[] { "add", "--", file.Path };
		_ = await Core.GitAsync(
			args,
			repository.Path,
			OperationStageManualResolution
		).ConfigureAwait(false);
	}

	static async Task<string> GetHeadShaAsync(Repository repository) {
		if ( repository == null )
			throw new ArgumentNullException(nameof(repository));

		var result = await Core.GitAsync(
			new[] { "rev-parse", "HEAD" },
			repository.Path,
			OperationGetHeadSha
		).ConfigureAwait(false);

		return result.Stdout.Trim();
	}
}