ビルド前イベントを活用する、その12

ビルド前イベントで、参照しているライブラリを列挙して、その git ハッシュと、ブランチ名を取得するツールを製作してみましょう。

現在のプロジェクトが参照しているライブラリファイルの情報は、*.csproj に書いてありますので、ツールにはプロジェクトファイルのパスを与えてください。

「外部参照しているライブラリのファイル」と「プロジェクトに取り込んでしまったライブラリのファイル」はプロジェクト開発運用上では大きな違いがあります。

「プロジェクトに取り込んでしまったライブラリのファイル」は自分のプロジェクトの更新において git のコミットでスナップショットがとられますので問題ありませんが、「外部参照しているライブラリのファイル」はスナップショットがとられませんので、なんとかしてその情報を記録しておく必要があります。

git では、Submodule や Subtree という仕組みがありますが、どちらも手続きが煩雑であるし、その手動操作を忘れるとアウトです。そういった理由から自動でそういうことができるツールが必要なのです。前置きが長くなりました。

ビルド前イベントではプロジェクトファイルのパスを示すのに $(ProjectPath) という定義済みマクロが利用できます。大きくは下記の流れになっています。

・実行時引数で与えられたプロジェクトファイルのパスから、プロジェクトファイル *.csproj の中身を解析して <Compile Link = "" > で子ノードに <Link> があるものを列挙する。(50行目)

・外部参照しているライブラリのファイルパスをリストアップする。(63行目)

・いったん外部参照しているライブラリのファイルパスを Console.WriteLine(); する。(70行目)

・ライブラリパスと同階層に Gitリポジトリ( .git )があるか確認する。(90行目)

・Gitリポジトリがあるディレクトリで ”git diff --name-only" と "git diff --cached --name-only" コマンドで、ライブラリに変更がかかっていないか確認する。(104行目)

・変更がかかっていたらビルドを中止するため return -1; する。(135行目)

・変更がかかっていなければ、"git rev-parse HEAD" と "git rev-parse --abbrev-ref HEAD" コマンドで、コミットハッシュとブランチ名を取得して、そのとき Git リポジトリのあるディレクトリも含めてリストに記録する。(110行目、119行目)

・記録した内容を、Console.WriteLine(); する。(116行目)

・記録した内容を、ストリームライタで *.csproj と同じ場所に "external_lib_info.txt" として保存する。(148行目)

using System;
using System.Collections.Generic;
using System.Xml;
using System.IO;
using System.Text;

using System.Runtime.CompilerServices;
using System.Diagnostics;

namespace tool
{
	internal class Program
	{

		// 外部参照ライブラリの git の情報を記述するファイルの名前.
		const String FILENAME_EXT_LIB_INFO = "external_lib_info.txt";

		static int Main( string [] args )
		{

			if ( args.Length <= 0 )
			{
				Console.WriteLine( "プロジェクトファイルのパスが指定されていません." );

				// ビルドを中止する.
				return -1;
			}

			String filepath_prj = args[0];

			if ( !( File.Exists( filepath_prj )) )
			{
				Console.WriteLine( filepath_prj );
				Console.WriteLine( "対象のプロジェクトファイルがありません." );

				// ビルドを中止する.
				return -1;
			}

			// プロジェクトファイルが含まれているディレクトリを取得する.
			String dir_prj = Path.GetDirectoryName( filepath_prj );

			// プロジェクトファイルと同じ階層に外部参照ライブラリの情報が記録されたファイルを配置したい.
			String fp_out = Path.Combine( dir_prj, FILENAME_EXT_LIB_INFO );

			// ここに外部参照ライブラリのファイルパスがリストアップされる.
			List <String> ListFpExtLib = new List<String>(0);

			// そのプロジェクトファイルに記述されている外部参照ライブラリのパスをリスト取得する.
			bool ret = GetFilePathListOfExternalLibrary( ListFpExtLib, filepath_prj );
			if ( !ret )
			{
				Console.WriteLine( $"プロジェクトファイルの解析に失敗しました.\t{filepath_prj}" );

				// ビルドを中止する.
				return -1;
			}

			// すべてプロジェクトに取り込んだライブラリを利用しているときは、.
			// true でもどってきても ListFpExtLib の個数がゼロである.

			// 外部参照ファイルがあったかどうか.
			if ( ListFpExtLib.Count > 0 )
			{

				// ソートする.
				ListFpExtLib.Sort();

				// いったん対象の外部参照ライブラリの情報をループで出力する.
				for ( int k = 0; k < ListFpExtLib.Count; k++ )
				{
					// 参照ライブラリのファイルパス.
					Console.WriteLine( ListFpExtLib[k] );
				}

				// 後ほどファイル保存するときに使うストリングビルダを生成する.
				StringBuilder sb = new StringBuilder();

				// gitコマンドを開始することを知らせる.
				Console.WriteLine( "git command begin." );

				// 外部参照ライブラリが配置されているディレクトリで Git コマンドを発行する.
				for ( int k = 0; k < ListFpExtLib.Count; k++ )
				{

					// 外部参照ライブラリのファイルパス.
					String fp = ListFpExtLib[k];
					
					// そのファイルと同じ階層にgitリポジトリが存在するか?
					if ( IsGitRepositoryInSameDirectoryAsFile( fp ))
					{

						String dir = Path.GetDirectoryName( fp );

						// 対象のファイルと同じ階層にある .git リポジトリのディレクトリパス.
						String dirpath_dot_git = Path.Combine( dir, ".git" );

						// .git リポジトリが含まれているディレクトリで git コマンドを実行させたい.
						// リポジトリの親ディレクトリがそこにあたる.
						DirectoryInfo di = new DirectoryInfo( dirpath_dot_git );
						String dir_working = di.Parent.FullName;

						// そのディレクトリに変更がかかったファイルがあったかどうか.
						if ( IsWorkingDirectoryClear( dir_working ) )
						{

							// そのディレクトリ内のファイル群のコミットハッシュを取得する.
							String hash = "";
							String branch = "";
							if ( GetCommitHashAndBranchName( dir_working, ref hash, ref branch ))
							{
								// 取得されたコミットハッシュとブランチ名とワーキングディレクトリ.
								String str_line = $"{dir_working}\t{hash}\t{branch}";

								// VisualStudioコンソールに出力する.
								Console.WriteLine( str_line );

								// 後ほどファイル保存するときに使うストリングビルダにも追加する.
								sb.AppendLine( str_line );
							}
							else
							{
								Console.WriteLine( $"ハッシュまたはブランチ名が取得できませんでした.\t{dir_working}" );

								// ビルドを中止する.
								return -1;
							}

						}
						else
						{
							Console.WriteLine( $"未コミットのファイルがあります.\t{dir_working}" );

							// ビルドを中止する.
							return -1;
						}

					}

				}

				// gitコマンドを終了することを知らせる.
				Console.WriteLine( "git command end." );

				// コミットハッシュとブランチ名とワーキングディレクトリの情報をファイルに記録したい.
				try
				{
					using ( StreamWriter sw = new StreamWriter( fp_out, false, new UTF8Encoding( false )))
					{
						String str_all = sb.ToString().Trim();
						sw.WriteLine( str_all );
					}
				}
				catch ( Exception excp )
				{
					Console.WriteLine( excp.Message );

					// ビルドを中止する.
					return -1;
				}

			}
			else
			{
				Console.WriteLine( "外部参照ライブラリはありませんでした." );
			}

			// デバッグで止めたいときはこれをコメントイン.
			//Console.ReadKey();

			// ビルドに突入する.
			return 0;

		}

		// ここからメソッド.

		static bool GetFilePathListOfExternalLibrary( List<String> ListFilePathExternalLibrary, String filepath_project )
		{

			if ( ListFilePathExternalLibrary == null )
			{
				return false;
			}

			if ( !File.Exists( filepath_project ))
			{
				return false;
			}

			String dirpath_project = Path.GetDirectoryName( filepath_project );

			// XMLドキュメントを読み込む.
			XmlDocument doc = new XmlDocument();
			doc.Load( filepath_project );

			// これはユーザーが適当に決める.
			const String NAME_SPACE = "ns";

			// うまくいかないときは csproj ファイルを目視して確認せよ.
			const String URI = "http://schemas.microsoft.com/developer/msbuild/2003";

			// 名前空間マネージャを作成する.
			XmlNamespaceManager nmsp_mngr = new XmlNamespaceManager( doc.NameTable );
			nmsp_mngr.AddNamespace( NAME_SPACE, URI );

			// <Compile>というすべてのノードをリストとして取得する.
			String xpath_COMPILE = $"//{NAME_SPACE}:Compile";
			XmlNodeList node_list = doc.SelectNodes( xpath_COMPILE, nmsp_mngr );

			// <Compile>というノードがないXMLファイルはプロジェクトファイルではない.
			if ( node_list.Count <= 0 )
			{
				return  false;
			}

			for ( int k = 0; k < node_list.Count; k++ )
			{

				// <Compile> ノードをひとつ取り出す.
				XmlNode node = node_list[k];

				// そこに <Link> という子ノードがあるかチェックする.
				String xpath_LINK = $"{NAME_SPACE}:Link";
				XmlNode node_child = node.SelectSingleNode( xpath_LINK, nmsp_mngr );
				if ( node_child != null )
				{
					// 子ノードの親、つまり <Compile> の Include アトリビュートを参照する.
					XmlAttribute attr = node.Attributes [ "Include" ];

					// そのアトリビュートがあるかどうか.
					if ( attr != null )
					{
						// 相対パスで記述されている可能性が高い.
						String fp_relative = attr.Value;

						// 絶対パスに変換する.
						String tmp = Path.Combine( dirpath_project, fp_relative );
						String fp_absolute = Path.GetFullPath( tmp );

						// リストに追加する.
						ListFilePathExternalLibrary.Add( fp_absolute );
					}

				}

			}

			return true;

		}

		// 同一の階層にgitリポジトリがあるかどうか.
		static bool IsGitRepositoryInSameDirectoryAsFile( string filepath )
		{

			if ( filepath == null )
			{
				return false;
			}

			if ( filepath == "" )
			{
				return false;
			}

			string dir = Path.GetDirectoryName( filepath );
			if ( dir == null || dir == "" )
			{
				return false;
			}

			string dir_dot_git = Path.Combine( dir, ".git" );
			bool ret = Directory.Exists( dir_dot_git );

			return ret;

		}

		// gitワーキングディレクトリ内のファイル群に変更がかかっていないかどうか.
		static bool IsWorkingDirectoryClear( string dir_working )
		{

			if ( dir_working == null )
			{
				return false;
			}

			if ( dir_working == "" )
			{
				return false;
			}

			const String ARGV_A = "diff --name-only";
			const String ARGV_B = "diff --cached --name-only";

			// ワーキングツリーに変更があったかどうか?
			// 変更がなければ何も戻ってこない(UNIX的ダンマリ).
			List<String> retout_a = ExecGitCommand( ARGV_A, dir_working );

			// ステージに変更があったかどうか?
			// 変更がなければ何も戻ってこない(UNIX的ダンマリ).
			List<String> retout_b = ExecGitCommand( ARGV_B, dir_working );

			if (( retout_a.Count == 0 ) && ( retout_b.Count == 0 ))
			{
				return true;
			}
			else
			{
				return false;
			}

		}

		// gitワーキングディレクトリのコミットハッシュとブランチ名を取得する.
		static bool GetCommitHashAndBranchName(
						String dir_working,
						ref String commit_hash,
						ref String branch_name
						)
		{

			String dir_dot_git = Path.Combine( dir_working, ".git" );
			if ( !Directory.Exists( dir_dot_git ))
			{
				return false; // warning.
			}

			// A: アクティブブランチのコミットハッシュを取得する.
			// B: アクティブブランチの名前を取得する.
			const String ARGV_A = "rev-parse HEAD";
			const String ARGV_B = "rev-parse --abbrev-ref HEAD";

			// Gitコマンドを実行する.
			List<String> outA = ExecGitCommand( ARGV_A, dir_working );
			List<String> outB = ExecGitCommand( ARGV_B, dir_working );

			if ( outA.Count == 0 || outA[0].Contains( "fatal:" ))
			{
				commit_hash = "";
				branch_name = "";
				return false; // warning.
			}

			if ( outB.Count == 0 || outB[0].Contains( "fatal:" ))
			{
				commit_hash = "";
				branch_name = "";
				return false; // warning.
			}

			// 引数に戻す.
			commit_hash = outA[0];
			branch_name = outB[0];

			return true;;

		}

		// git のコマンドを実行する.
		static List<string> ExecGitCommand( string arguments, string working_directory )
		{

			ProcessStartInfo psi = new ProcessStartInfo();
			psi.FileName               = "git";
			psi.Arguments              = arguments;
			psi.WorkingDirectory       = working_directory;
			psi.RedirectStandardOutput = true;
			psi.RedirectStandardError  = true;
			psi.StandardOutputEncoding = Encoding.UTF8; // ここを指定しないと日本語は文字化けする.
			psi.StandardErrorEncoding  = Encoding.UTF8; // ここを指定しないと日本語は文字化けする.
			psi.UseShellExecute        = false;
			psi.CreateNoWindow         = true;

			List<String> output = new List<string>();

			try
			{

				using ( Process proc = Process.Start( psi ) )
				{

					while ( !proc.StandardOutput.EndOfStream )
					{
						output.Add( proc.StandardOutput.ReadLine() );
					}

					string err = proc.StandardError.ReadToEnd();
					proc.WaitForExit();

					if ( proc.ExitCode != 0 )
					{
						throw new Exception( err );
					}

				}

			}
			catch ( Exception excp )
			{
				output.Add( excp.Message );
			}

			return output;

		}

		// デバッグ用のメソッド. C言語の __FILE__ や __LINE__ にあたる.
		static class Log
		{
			public static void Mark(
				String str_msg = "",
				[CallerFilePath] String file_path = "",
				[CallerLineNumber] int line_number = 0,
				[CallerMemberName] String member_name = ""
				)
			{
				String file_name = Path.GetFileName( file_path );
				Console.WriteLine( $"[{file_name}:{line_number}] {member_name}() {str_msg}." );
			}
		}

	}
}

 

VisualStudio のビルド前コマンドは下記のようにします。

tool.exe はプロジェクトファイルと同じ場所 $(ProjectDir) に置いてください。実行時引数はプロジェクトファイルのパス $(ProjectPath) を与えてください。

call "$(ProjectDir)tool.exe" "$(ProjectPath)"

または

"$(ProjectDir)tool.exe" "$(ProjectPath)"

としてください。

下記のようなビルド出力が得られます。

リビルドを開始しました...
1>------ すべてのリビルド開始: プロジェクト:aaa, 構成: Release x64 ------
1>  C:\develop\GazoYaroLib\GazoYaroBitmapUtility\GazoYaroBitmapUtility.cs
1>  C:\develop\GazoYaroLib\GazoYaroImageProcessing\GazoYaroImageProcessing.cs
1>  git command begin.
1>  C:\develop\GazoYaroLib\GazoYaroBitmapUtility	f9d45939195033cdcfe86a37d17f37e8a37c884f	master
1>  C:\develop\GazoYaroLib\GazoYaroImageProcessing	aadbe72a6d09605ebc23bdcd94a3214695634675	master
1>  git command end.
1>  aaa -> C:\develop\sss\out\x64\Release\aaa.exe
========== すべて再構築: 1 正常終了、0 失敗、0 スキップ ==========
========== リビルド は 10:30 に開始され、01.219 秒 かかりました ==========

同時にプロジェクトファイルと同じディレクトリに "external_lib_info.txt" というのが出来上がっているはずです。中身は下記のようになっています。

C:\develop\GazoYaroLib\GazoYaroBitmapUtility	f9d45939195033cdcfe86a37d17f37e8a37c884f	master
C:\develop\GazoYaroLib\GazoYaroImageProcessing	aadbe72a6d09605ebc23bdcd94a3214695634675	master