Editor/MyEditorMenu.cs
using Editor;
using Sandbox;
using Sandbox.Diagnostics;
using System;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

interface IHasChanges
{
	string Changes { get; set; }
}

public static class MyEditorMenu
{
	private static readonly Logger Log = new( "Changelog Helper" );

	static async Task<string> GetCurrentPublishedHash()
	{
		var pkg = await Package.Fetch( Game.Ident, false );
		string hash = null;

		// pkg.Revision is a Sandbox.PackageRevision or something like that which has the 'Changes' property we actually want

		if ( pkg.Revision.GetSerialized().TryGetProperty( "Changes", out var prop ) )
		{
			hash = prop.GetValue<string>();
		}

		if ( !VerifyHash( hash ) )
			hash = null;

		return hash;
	}

	/// <summary>
	/// Does it look like a hash?
	/// </summary>
	static bool VerifyHash( string hash )
	{
		return Regex.IsMatch( hash, @"^[a-z0-9]+$" );
	}

	/// <summary>
	/// Does not verify arguments.
	/// </summary>
	static async Task<string> ExecuteGit( string arguments )
	{
		var proc = new Process
		{
			StartInfo = new ProcessStartInfo
			{
				FileName = "git",
				Arguments = arguments,
				WorkingDirectory = Project.Current.GetRootPath(),
				RedirectStandardOutput = true,
				RedirectStandardError = true,
				UseShellExecute = false,
				CreateNoWindow = true,
			}
		};

		proc.Start();

		var output = proc.StandardOutput.ReadToEnd();
		var errors = proc.StandardError.ReadToEnd();

		await proc.WaitForExitAsync();

		if ( !string.IsNullOrEmpty( errors ) )
		{
			Log.Error( errors );
		}

		return output;
	}

	static async Task<string> GetChangelog()
	{
		var hash = await GetCurrentPublishedHash();

		if ( string.IsNullOrEmpty( hash ) )
		{
			throw new Exception( "Unable to get commit hash from previous publish" );
		}

		// this should already be filtered out by VerifyHash, but nothing wrong with more asserts :)
		Assert.False( hash.Contains( '"' ) );
		Assert.False( hash.Contains( ' ' ) );

		var log = await ExecuteGit( $"log --reverse --format=%s {hash}..HEAD" );

		if ( string.IsNullOrEmpty( log ) )
		{
			throw new Exception( "Generated changelog was empty!" );
		}

		return log;
	}

	static async Task<string> GetCurrentRevision()
	{
		var rev = await ExecuteGit( $"rev-parse HEAD" );

		if ( string.IsNullOrEmpty( rev ) )
		{
			throw new Exception( "Something went wrong!" );
		}

		return rev[0..7];
	}

	[Menu( "Editor", "Changelog Helper/Copy info" )]
	public static async void CopyInfo()
	{
		var changelog = "";
		try
		{
			changelog = await GetChangelog();
		}
		catch
		{
			// It's fine if we don't have a changelog (like for initial commits)
		}

		var revision = "";
		try
		{
			revision = await GetCurrentRevision();
		}
		catch ( Exception e )
		{
			// It's *not* fine if we can't find the current revision.
			Log.Error( e );
			EditorUtility.DisplayDialog( "Error", $"{e.Message} See Console output for more info." );
			return;
		}

		if ( string.IsNullOrEmpty( changelog ) )
		{
			EditorUtility.Clipboard.Copy( revision );
			Log.Warning( "Failed to get changelog, but the current revision was copied." );
		}
		else
		{
			EditorUtility.Clipboard.Copy( $"{revision}\n{changelog}" );
			Log.Info( "Copied!" );
		}
	}
}