Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

[C#] gitの変更前コミットと変更後コミット間で、変更が入った箇所が属するメソッドの一覧を出す

Last updated at Posted at 2025-10-21

github copilot(gpt5mini)が吐いたコード。

まだ理解できてない。後追いでやる。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

class DiffMethodMapper
{
    static int Main(string[] args)
    {
        if (args.Length < 2)
        {
            Console.Error.WriteLine("Usage: DiffMethodMapper <commitA> <commitB>");
            return 2;
        }

        string commitA = args[0];
        string commitB = args[1];

        // 変更された C# ファイル一覧を取得
        var filesTxt = RunGit("diff --name-only --no-color " + commitA + " " + commitB + " -- \"*.cs\"");
        if (filesTxt == null) return 1;

        var files = filesTxt.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
                            .Select(f => f.Trim())
                            .Where(f => f.Length > 0).ToArray();

        if (files.Length == 0)
        {
            Console.WriteLine("No C# files changed.");
            return 0;
        }

        foreach (var file in files)
        {
            Console.WriteLine("File: " + file);

            // diff の hunk を取得 (対象ファイルのみ)
            var diff = RunGit("diff --no-color -U0 " + commitA + " " + commitB + " -- \"" + file + "\"");
            if (diff == null)
            {
                Console.WriteLine("  (failed to get diff)");
                continue;
            }

            var ranges = ParseAddedLineRanges(diff);
            if (ranges.Count == 0)
            {
                Console.WriteLine("  (no added/changed lines)");
                continue;
            }

            // ファイルの新しい内容を取得(commitB の版)
            var fileContent = RunGitQuiet("show " + commitB + ":" + EscapePathForGitShow(file));
            if (fileContent == null)
            {
                Console.WriteLine("  (file not present in commit " + commitB + ")");
                continue;
            }

            var tree = CSharpSyntaxTree.ParseText(fileContent);
            var root = tree.GetRoot();
            var text = tree.GetText();

            // 各範囲について、範囲内の行の代表(開始行)を使ってメンバーを求める
            var reported = new HashSet<string>();
            foreach (var r in ranges)
            {
                for (int ln = r.Start; ln <= r.End; ln++)
                {
                    if (ln < 1 || ln > text.Lines.Count) continue;
                    var pos = text.Lines[ln - 1].Start;
                    var token = root.FindToken(pos);
                    var node = token.Parent;
                    var member = GetEnclosingMemberFullName(node);
                    if (member == null) member = $"<global line {ln}>";
                    if (reported.Add(member))
                    {
                        Console.WriteLine($"  {member}  (lines {r.Start}-{r.End})");
                    }
                    // ひとつの範囲から重複出力を避けるため break して次の範囲へするか、
                    // ここはラップポリシーに依存。今回は範囲内全行で検出するが、
                    // 同じメンバーは reported によって一度だけ表示される。
                }
            }
        }

        return 0;
    }

    // git コマンドを実行して stdout を返す。失敗時は null。
    static string RunGit(string args)
    {
        try
        {
            var psi = new ProcessStartInfo("git", args)
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
                StandardOutputEncoding = System.Text.Encoding.UTF8,
                StandardErrorEncoding = System.Text.Encoding.UTF8
            };
            using (var p = Process.Start(psi))
            {
                string outp = p.StandardOutput.ReadToEnd();
                string err = p.StandardError.ReadToEnd();
                p.WaitForExit();
                if (p.ExitCode != 0)
                {
                    Console.Error.WriteLine("git failed: " + err.Trim());
                    return null;
                }
                return outp;
            }
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine("Failed to run git: " + ex.Message);
            return null;
        }
    }

    // git show は二重引用やスペースがあると失敗するので、パスをコロン区切りで安全に渡すための簡易エスケープ
    static string EscapePathForGitShow(string path)
    {
        // git show <commit>:<path> — path がリポジトリ内相対パスである前提
        // Windows のパス区切りを / に変換
        return path.Replace('\\', '/');
    }

    // git show を実行して出力を返す(NULL で失敗)
    static string RunGitQuiet(string args)
    {
        return RunGit(args);
    }

    // diff の hunk から +側行範囲を抽出する
    static List<(int Start, int End)> ParseAddedLineRanges(string diff)
    {
        var list = new List<(int Start, int End)>();
        var lines = diff.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
        var regex = new Regex(@"^\@\@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@");
        foreach (var l in lines)
        {
            var m = regex.Match(l);
            if (m.Success)
            {
                int start = int.Parse(m.Groups[1].Value);
                int count = 1;
                if (m.Groups[2].Success) count = int.Parse(m.Groups[2].Value);
                int end = start + Math.Max(0, count - 1);
                list.Add((start, end));
            }
        }
        return list;
    }

    // Roslyn のノードから、包含する最上位のメンバー名(Namespace.Type...Member)を組み立てて返す
    // ...existing code...
    static string GetEnclosingMemberFullName(SyntaxNode node)
    {
        if (node == null) return null;

        // メソッド系(BaseMethodDeclaration: メソッド/コンストラクタ/デストラクタ)かローカル関数のみを探す
        SyntaxNode methodNode = node;
        while (methodNode != null &&
               !(methodNode is BaseMethodDeclarationSyntax) &&
               !(methodNode is LocalFunctionStatementSyntax))
        {
            methodNode = methodNode.Parent;
        }
        if (methodNode == null) return null;

        string memberName = null;
        if (methodNode is MethodDeclarationSyntax md) memberName = md.Identifier.Text;
        else if (methodNode is ConstructorDeclarationSyntax cd) memberName = cd.Identifier.Text;
        else if (methodNode is DestructorDeclarationSyntax dd) memberName = dd.Identifier.Text;
        else if (methodNode is LocalFunctionStatementSyntax lf) memberName = lf.Identifier.Text;
        else return null; // 念のため、それ以外は無視

        // 型名チェーン(メソッドを含む最上位の型まで遡る)
        var typeNames = new List<string>();
        var n = methodNode;
        while (n != null)
        {
            if (n is TypeDeclarationSyntax tds)
            {
                typeNames.Insert(0, tds.Identifier.Text);
            }
            n = n.Parent;
        }

        // namespace(ファイルスコープ namespace 対応)
        string ns = null;
        var p = methodNode;
        while (p != null && !(p is NamespaceDeclarationSyntax))
            p = p.Parent;
        if (p is NamespaceDeclarationSyntax nds)
        {
            ns = nds.Name.ToString();
        }
        else
        {
            var root = methodNode.SyntaxTree.GetRoot();
            var fileScoped = root.DescendantNodes().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
            if (fileScoped != null) ns = fileScoped.Name.ToString();
        }

        var fullnameParts = new List<string>();
        if (!string.IsNullOrEmpty(ns)) fullnameParts.Add(ns);
        if (typeNames.Count > 0) fullnameParts.Add(string.Join(".", typeNames));
        if (!string.IsNullOrEmpty(memberName)) fullnameParts.Add(memberName);

        if (fullnameParts.Count == 0) return null;
        return string.Join(".", fullnameParts);
    }
    // ...existing code...
}

ビルドして出来上がったexeをa.exeとすると、

c#を含むリポジトリのtop階層にa.exeをおいて、

a.exe d50bc98bbb0268c3679b0ebd8f5a3b4691eb44e6 cddf9746145a626e906a2832800e6a5bb15a60f8

などとすると、そのコミット間の変化点が属するメソッドを抜き出せる。

※第一引数が変更前、第二が変更後。

ファイル名とメソッド名、メソッドのコードを出すようにした

すごいなコパイロット、、、

// ...existing code...
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

class DiffMethodMapper
{
    static int Main(string[] args)
    {
        if (args.Length < 2)
        {
            Console.Error.WriteLine("Usage: DiffMethodMapper <commitA> <commitB> [repoPath]");
            return 2;
        }

        string commitA = args[0];
        string commitB = args[1];
        string repoPath = args.Length >= 3 ? args[2] : null;

        var filesTxt = RunGit($"diff --name-only --no-color {commitA} {commitB} -- \"*.cs\"", repoPath);
        if (filesTxt == null) return 1;

        var files = filesTxt.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
                            .Select(f => f.Trim())
                            .Where(f => f.Length > 0).ToArray();

        if (files.Length == 0)
        {
            Console.WriteLine("No C# files changed.");
            return 0;
        }

        foreach (var file in files)
        {
            Console.WriteLine("File: " + file);

            var diff = RunGit($"diff --no-color -U0 {commitA} {commitB} -- \"{file}\"", repoPath);
            if (diff == null)
            {
                Console.WriteLine("  (failed to get diff)");
                continue;
            }

            var ranges = ParseAddedLineRanges(diff);
            if (ranges.Count == 0)
            {
                Console.WriteLine("  (no added/changed lines)");
                continue;
            }

            var fileContent = RunGitQuiet($"show {commitB}:{EscapePathForGitShow(file)}", repoPath);
            if (fileContent == null)
            {
                Console.WriteLine("  (file not present in commit " + commitB + ")");
                continue;
            }

            var tree = CSharpSyntaxTree.ParseText(fileContent);
            var root = tree.GetRoot();
            var text = tree.GetText();

            var reported = new HashSet<string>();

            foreach (var r in ranges)
            {
                // 代表行だけでなく範囲内をスキャンしてメソッドを拾う
                for (int ln = r.Start; ln <= r.End; ln++)
                {
                    if (ln < 1 || ln > text.Lines.Count) continue;
                    var pos = text.Lines[ln - 1].Start;
                    var token = root.FindToken(pos);
                    var node = token.Parent;
                    var methodNode = FindEnclosingMethodNode(node);
                    if (methodNode == null) continue;

                    var memberFullName = GetEnclosingMemberFullName(methodNode);
                    if (string.IsNullOrEmpty(memberFullName)) memberFullName = $"<method at line {ln}>";

                    if (reported.Add(memberFullName))
                    {
                        Console.WriteLine($"  Member: {memberFullName}  (lines {r.Start}-{r.End})");
                        var src = ExtractMemberSource(methodNode);
                        Console.WriteLine("---- source ----");
                        Console.WriteLine(src.TrimEnd());
                        Console.WriteLine("----------------");
                    }
                }
            }
        }

        return 0;
    }

    static string RunGit(string args, string repoPath = null)
    {
        try
        {
            var psi = new ProcessStartInfo("git", args)
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
                StandardOutputEncoding = System.Text.Encoding.UTF8,
                StandardErrorEncoding = System.Text.Encoding.UTF8
            };
            if (!string.IsNullOrEmpty(repoPath))
            {
                psi.WorkingDirectory = repoPath;
            }

            using (var p = Process.Start(psi))
            {
                string outp = p.StandardOutput.ReadToEnd();
                string err = p.StandardError.ReadToEnd();
                p.WaitForExit();
                if (p.ExitCode != 0)
                {
                    Console.Error.WriteLine("git failed: " + err.Trim());
                    return null;
                }
                return outp;
            }
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine("Failed to run git: " + ex.Message);
            return null;
        }
    }

    static string RunGitQuiet(string args, string repoPath = null)
    {
        return RunGit(args, repoPath);
    }

    static string EscapePathForGitShow(string path)
    {
        return path.Replace('\\', '/');
    }

    static List<(int Start, int End)> ParseAddedLineRanges(string diff)
    {
        var list = new List<(int Start, int End)>();
        var lines = diff.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
        var regex = new Regex(@"^\@\@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@");
        foreach (var l in lines)
        {
            var m = regex.Match(l);
            if (m.Success)
            {
                int start = int.Parse(m.Groups[1].Value);
                int count = 1;
                if (m.Groups[2].Success) count = int.Parse(m.Groups[2].Value);
                int end = start + Math.Max(0, count - 1);
                list.Add((start, end));
            }
        }
        return list;
    }

    static SyntaxNode FindEnclosingMethodNode(SyntaxNode node)
    {
        if (node == null) return null;
        SyntaxNode n = node;
        while (n != null &&
               !(n is BaseMethodDeclarationSyntax) &&
               !(n is LocalFunctionStatementSyntax))
        {
            n = n.Parent;
        }
        return n;
    }

    static string ExtractMemberSource(SyntaxNode methodNode)
    {
        if (methodNode == null) return null;
        return methodNode.ToFullString();
    }

    static string GetEnclosingMemberFullName(SyntaxNode node)
    {
        if (node == null) return null;

        string memberName = null;
        if (node is MethodDeclarationSyntax md) memberName = md.Identifier.Text;
        else if (node is ConstructorDeclarationSyntax cd) memberName = cd.Identifier.Text;
        else if (node is DestructorDeclarationSyntax dd) memberName = dd.Identifier.Text;
        else if (node is LocalFunctionStatementSyntax lf) memberName = lf.Identifier.Text;
        else memberName = "<unknown>";

        var typeNames = new List<string>();
        var n = node;
        while (n != null)
        {
            if (n is TypeDeclarationSyntax tds)
            {
                typeNames.Insert(0, tds.Identifier.Text);
            }
            n = n.Parent;
        }

        string ns = null;
        var p = node;
        while (p != null && !(p is NamespaceDeclarationSyntax))
            p = p.Parent;
        if (p is NamespaceDeclarationSyntax nds)
        {
            ns = nds.Name.ToString();
        }
        else
        {
            var root = node.SyntaxTree.GetRoot();
            var fileScoped = root.DescendantNodes().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
            if (fileScoped != null) ns = fileScoped.Name.ToString();
        }

        var parts = new List<string>();
        if (!string.IsNullOrEmpty(ns)) parts.Add(ns);
        if (typeNames.Count > 0) parts.Add(string.Join(".", typeNames));
        if (!string.IsNullOrEmpty(memberName)) parts.Add(memberName);

        return parts.Count == 0 ? null : string.Join(".", parts);
    }
}
// ...existing code...

ビルドして出来上がったexeをa.exeとすると、

c#を含むリポジトリのtop階層にa.exeをおいて、

a.exe d50bc98bbb0268c3679b0ebd8f5a3b4691eb44e6 cddf9746145a626e906a2832800e6a5bb15a60f8 C:\git\FileVerUpTool

などとすると、そのコミット間の変化点が属するメソッドを抜き出せる。

※第一引数が変更前コミット、第二が変更後。第三引数は、対象のリポジトリの先頭パス。

差分のあるファイル名と変更メソッド名をcsv形式で列挙するようにした

a.exeの呼び方は同じ


// ...existing code...
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

class DiffMethodMapper
{
    static int Main(string[] args)
    {
        if (args.Length < 2)
        {
            Console.Error.WriteLine("Usage: DiffMethodMapper <commitA> <commitB> [repoPath]");
            return 2;
        }

        string commitA = args[0];
        string commitB = args[1];
        string repoPath = args.Length >= 3 ? args[2] : null;

        var filesTxt = RunGit($"diff --name-only --no-color {commitA} {commitB} -- \"*.cs\"", repoPath);
        if (filesTxt == null) return 1;

        var files = filesTxt.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
                            .Select(f => f.Trim())
                            .Where(f => f.Length > 0).ToArray();

        if (files.Length == 0)
        {
            // 出力はCSVのみにするため何も出さず正常終了
            return 0;
        }

        foreach (var file in files)
        {
            var diff = RunGit($"diff --no-color -U0 {commitA} {commitB} -- \"{file}\"", repoPath);
            if (diff == null) continue;

            var ranges = ParseAddedLineRanges(diff);
            if (ranges.Count == 0) continue;

            var fileContent = RunGitQuiet($"show {commitB}:{EscapePathForGitShow(file)}", repoPath);
            if (fileContent == null) continue;

            var tree = CSharpSyntaxTree.ParseText(fileContent);
            var root = tree.GetRoot();
            var text = tree.GetText();

            var reported = new HashSet<string>(StringComparer.Ordinal);

            foreach (var r in ranges)
            {
                for (int ln = r.Start; ln <= r.End; ln++)
                {
                    if (ln < 1 || ln > text.Lines.Count) continue;
                    var pos = text.Lines[ln - 1].Start;
                    var token = root.FindToken(pos);
                    var node = token.Parent;
                    var methodNode = FindEnclosingMethodNode(node);
                    if (methodNode == null) continue;

                    var memberFullName = GetEnclosingMemberFullName(methodNode);
                    if (string.IsNullOrEmpty(memberFullName)) memberFullName = $"<method at line {ln}>";

                    // ユニークキーはファイルパス + メンバ名
                    var key = file.Replace('\\', '/') + "|" + memberFullName;
                    if (reported.Add(key))
                    {
                        // CSV形式: path (forward-slash), comma-space, fully-qualified-member-name
                        Console.WriteLine($"{file.Replace('\\', '/')}, {memberFullName}");




                        //Console.WriteLine("copilotへの指示文言をクリップボードにコピーしました。");
                        Console.ReadLine();
                    }
                }
            }
        }

        return 0;
    }

    static string RunGit(string args, string repoPath = null)
    {
        try
        {
            var psi = new ProcessStartInfo("git", args)
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
                StandardOutputEncoding = System.Text.Encoding.UTF8,
                StandardErrorEncoding = System.Text.Encoding.UTF8
            };
            if (!string.IsNullOrEmpty(repoPath))
            {
                psi.WorkingDirectory = repoPath;
            }

            using (var p = Process.Start(psi))
            {
                string outp = p.StandardOutput.ReadToEnd();
                string err = p.StandardError.ReadToEnd();
                p.WaitForExit();
                if (p.ExitCode != 0)
                {
                    Console.Error.WriteLine("git failed: " + err.Trim());
                    return null;
                }
                return outp;
            }
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine("Failed to run git: " + ex.Message);
            return null;
        }
    }

    static string RunGitQuiet(string args, string repoPath = null)
    {
        return RunGit(args, repoPath);
    }

    static string EscapePathForGitShow(string path)
    {
        return path.Replace('\\', '/');
    }

    static List<(int Start, int End)> ParseAddedLineRanges(string diff)
    {
        var list = new List<(int Start, int End)>();
        var lines = diff.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
        var regex = new Regex(@"^\@\@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@");
        foreach (var l in lines)
        {
            var m = regex.Match(l);
            if (m.Success)
            {
                int start = int.Parse(m.Groups[1].Value);
                int count = 1;
                if (m.Groups[2].Success) count = int.Parse(m.Groups[2].Value);
                int end = start + Math.Max(0, count - 1);
                list.Add((start, end));
            }
        }
        return list;
    }

    static SyntaxNode FindEnclosingMethodNode(SyntaxNode node)
    {
        if (node == null) return null;
        SyntaxNode n = node;
        while (n != null &&
               !(n is BaseMethodDeclarationSyntax) &&
               !(n is LocalFunctionStatementSyntax) &&
               !(n is AccessorDeclarationSyntax)) // プロパティ/インデクサの get/set もメソッド扱いする
        {
            n = n.Parent;
        }
        return n;
    }

    static string ExtractMemberSource(SyntaxNode methodNode)
    {
        if (methodNode == null) return null;
        return methodNode.ToFullString();
    }

    static string GetEnclosingMemberFullName(SyntaxNode node)
    {
        if (node == null) return null;

        string memberName = null;
        if (node is MethodDeclarationSyntax md) memberName = md.Identifier.Text;
        else if (node is ConstructorDeclarationSyntax cd) memberName = cd.Identifier.Text;
        else if (node is DestructorDeclarationSyntax dd) memberName = dd.Identifier.Text;
        else if (node is LocalFunctionStatementSyntax lf) memberName = lf.Identifier.Text;
        else if (node is AccessorDeclarationSyntax ad)
        {
            // プロパティ/イベントの accessor は親のプロパティ名を付加
            var parentProp = ad.Parent?.Parent as BasePropertyDeclarationSyntax;
            var propName = parentProp is PropertyDeclarationSyntax pd ? pd.Identifier.Text :
                           parentProp is IndexerDeclarationSyntax ? "this" : "<prop>";
            memberName = propName + "." + ad.Keyword.Text; // e.g. MyProp.get
        }
        else memberName = "<unknown>";

        var typeNames = new List<string>();
        var n = node;
        while (n != null)
        {
            if (n is TypeDeclarationSyntax tds)
            {
                typeNames.Insert(0, tds.Identifier.Text);
            }
            n = n.Parent;
        }

        string ns = null;
        var p = node;
        while (p != null && !(p is NamespaceDeclarationSyntax))
            p = p.Parent;
        if (p is NamespaceDeclarationSyntax nds)
        {
            ns = nds.Name.ToString();
        }
        else
        {
            var root = node.SyntaxTree.GetRoot();
            var fileScoped = root.DescendantNodes().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
            if (fileScoped != null) ns = fileScoped.Name.ToString();
        }

        var parts = new List<string>();
        if (!string.IsNullOrEmpty(ns)) parts.Add(ns);
        if (typeNames.Count > 0) parts.Add(string.Join(".", typeNames));
        if (!string.IsNullOrEmpty(memberName)) parts.Add(memberName);

        return parts.Count == 0 ? null : string.Join(".", parts);
    }
}
// ...existing code...

変更のあったメソッドをAIにレビューしてもらうための指示文言をクリップボードにコピーする

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Windows.Forms;

class DiffMethodMapper
{
    [STAThread]
    static int Main(string[] args)
    {
        if (args.Length < 2)
        {
            Console.Error.WriteLine("Usage: DiffMethodMapper <commitA> <commitB> [repoPath]");
            return 2;
        }

        string commitA = args[0];
        string commitB = args[1];
        string repoPath = args.Length >= 3 ? args[2] : null;

        var filesTxt = RunGit($"diff --name-only --no-color {commitA} {commitB} -- \"*.cs\"", repoPath);
        if (filesTxt == null) return 1;

        var files = filesTxt.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
                            .Select(f => f.Trim())
                            .Where(f => f.Length > 0).ToArray();

        if (files.Length == 0)
        {
            // 出力はCSVのみにするため何も出さず正常終了
            return 0;
        }

        foreach (var file in files)
        {
            // ファイル全文を読み込む処理を追加
            string fileFullContent = null;
            try
            {
                fileFullContent = File.ReadAllText(file, System.Text.Encoding.UTF8);
            }
            catch (Exception ex)
            {
                Console.Error.WriteLine($"ファイルの読み込みに失敗しました: {file} - {ex.Message}");
                continue;
            }

            var diff = RunGit($"diff --no-color -U0 {commitA} {commitB} -- \"{file}\"", repoPath);
            if (diff == null) continue;

            var ranges = ParseAddedLineRanges(diff);
            if (ranges.Count == 0) continue;

            var fileContent = RunGitQuiet($"show {commitB}:{EscapePathForGitShow(file)}", repoPath);
            if (fileContent == null) continue;

            var tree = CSharpSyntaxTree.ParseText(fileContent);
            var root = tree.GetRoot();
            var text = tree.GetText();

            var reported = new HashSet<string>(StringComparer.Ordinal);

            foreach (var r in ranges)
            {
                for (int ln = r.Start; ln <= r.End; ln++)
                {
                    if (ln < 1 || ln > text.Lines.Count) continue;
                    var pos = text.Lines[ln - 1].Start;
                    var token = root.FindToken(pos);
                    var node = token.Parent;
                    var methodNode = FindEnclosingMethodNode(node);
                    if (methodNode == null) continue;

                    var memberFullName = GetEnclosingMemberFullName(methodNode);
                    if (string.IsNullOrEmpty(memberFullName)) memberFullName = $"<method at line {ln}>";

                    // ユニークキーはファイルパス + メンバ名
                    var key = file.Replace('\\', '/') + "|" + memberFullName;
                    if (reported.Add(key))
                    {
                        // CSV形式: path (forward-slash), comma-space, fully-qualified-member-name
                        Console.WriteLine($"{file.Replace('\\', '/')}, {memberFullName}");

                        var orderString = "";
                        orderString += $"以下のコードの「{memberFullName}」メソッドをレビューしてください。";
                        orderString += "レビュー結果には、レビューしたメソッド名を明記してください。";
                        orderString += "";
                        orderString += fileFullContent;

                        // クリップボードにコピー
                        try
                        {
                            Clipboard.SetText(orderString);
                            Console.WriteLine("  orderStringをクリップボードにコピーしました。");
                            Console.WriteLine("  AIチャットクライアントに貼り付けてレビュー依頼してください。");
                            Console.WriteLine("  レビューが終わったら、なにかキーを押してください。");
                        }
                        catch (Exception ex)
                        {
                            Console.Error.WriteLine("クリップボードへのコピーに失敗しました: " + ex.Message);
                        }


                        //Console.WriteLine("copilotへの指示文言をクリップボードにコピーしました。");
                        Console.ReadLine();
                    }
                }
            }
        }

        return 0;
    }

    static string RunGit(string args, string repoPath = null)
    {
        try
        {
            var psi = new ProcessStartInfo("git", args)
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
                StandardOutputEncoding = System.Text.Encoding.UTF8,
                StandardErrorEncoding = System.Text.Encoding.UTF8
            };
            if (!string.IsNullOrEmpty(repoPath))
            {
                psi.WorkingDirectory = repoPath;
            }

            using (var p = Process.Start(psi))
            {
                string outp = p.StandardOutput.ReadToEnd();
                string err = p.StandardError.ReadToEnd();
                p.WaitForExit();
                if (p.ExitCode != 0)
                {
                    Console.Error.WriteLine("git failed: " + err.Trim());
                    return null;
                }
                return outp;
            }
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine("Failed to run git: " + ex.Message);
            return null;
        }
    }

    static string RunGitQuiet(string args, string repoPath = null)
    {
        return RunGit(args, repoPath);
    }

    static string EscapePathForGitShow(string path)
    {
        return path.Replace('\\', '/');
    }

    static List<(int Start, int End)> ParseAddedLineRanges(string diff)
    {
        var list = new List<(int Start, int End)>();
        var lines = diff.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
        var regex = new Regex(@"^\@\@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@");
        foreach (var l in lines)
        {
            var m = regex.Match(l);
            if (m.Success)
            {
                int start = int.Parse(m.Groups[1].Value);
                int count = 1;
                if (m.Groups[2].Success) count = int.Parse(m.Groups[2].Value);
                int end = start + Math.Max(0, count - 1);
                list.Add((start, end));
            }
        }
        return list;
    }

    static SyntaxNode FindEnclosingMethodNode(SyntaxNode node)
    {
        if (node == null) return null;
        SyntaxNode n = node;
        while (n != null &&
               !(n is BaseMethodDeclarationSyntax) &&
               !(n is LocalFunctionStatementSyntax) &&
               !(n is AccessorDeclarationSyntax)) // プロパティ/インデクサの get/set もメソッド扱いする
        {
            n = n.Parent;
        }
        return n;
    }

    static string ExtractMemberSource(SyntaxNode methodNode)
    {
        if (methodNode == null) return null;
        return methodNode.ToFullString();
    }

    static string GetEnclosingMemberFullName(SyntaxNode node)
    {
        if (node == null) return null;

        string memberName = null;
        if (node is MethodDeclarationSyntax md) memberName = md.Identifier.Text;
        else if (node is ConstructorDeclarationSyntax cd) memberName = cd.Identifier.Text;
        else if (node is DestructorDeclarationSyntax dd) memberName = dd.Identifier.Text;
        else if (node is LocalFunctionStatementSyntax lf) memberName = lf.Identifier.Text;
        else if (node is AccessorDeclarationSyntax ad)
        {
            // プロパティ/イベントの accessor は親のプロパティ名を付加
            var parentProp = ad.Parent?.Parent as BasePropertyDeclarationSyntax;
            var propName = parentProp is PropertyDeclarationSyntax pd ? pd.Identifier.Text :
                           parentProp is IndexerDeclarationSyntax ? "this" : "<prop>";
            memberName = propName + "." + ad.Keyword.Text; // e.g. MyProp.get
        }
        else memberName = "<unknown>";

        var typeNames = new List<string>();
        var n = node;
        while (n != null)
        {
            if (n is TypeDeclarationSyntax tds)
            {
                typeNames.Insert(0, tds.Identifier.Text);
            }
            n = n.Parent;
        }

        string ns = null;
        var p = node;
        while (p != null && !(p is NamespaceDeclarationSyntax))
            p = p.Parent;
        if (p is NamespaceDeclarationSyntax nds)
        {
            ns = nds.Name.ToString();
        }
        else
        {
            var root = node.SyntaxTree.GetRoot();
            var fileScoped = root.DescendantNodes().OfType<FileScopedNamespaceDeclarationSyntax>().FirstOrDefault();
            if (fileScoped != null) ns = fileScoped.Name.ToString();
        }

        var parts = new List<string>();
        if (!string.IsNullOrEmpty(ns)) parts.Add(ns);
        if (typeNames.Count > 0) parts.Add(string.Join(".", typeNames));
        if (!string.IsNullOrEmpty(memberName)) parts.Add(memberName);

        return parts.Count == 0 ? null : string.Join(".", parts);
    }
}
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?