Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions Utilities/GitUtility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
namespace CodeMedic.Utilities;

/// <summary>
/// Utility class for Git-related operations.
/// </summary>
public static class GitUtility
{
/// <summary>
/// Checks if the specified directory is a Git repository.
/// </summary>
/// <param name="path">The path to check.</param>
/// <returns>True if the path is within a Git repository; otherwise, false.</returns>
public static bool IsGitRepository(string path)
{
var directory = new DirectoryInfo(path);

while (directory != null)
{
var gitDir = Path.Combine(directory.FullName, ".git");
if (Directory.Exists(gitDir) || File.Exists(gitDir))
{
return true;
}
directory = directory.Parent;
}

return false;
}

/// <summary>
/// Gets the root directory of the Git repository.
/// </summary>
/// <param name="path">A path within the repository.</param>
/// <returns>The repository root path, or null if not in a Git repository.</returns>
public static string? GetRepositoryRoot(string path)
{
var directory = new DirectoryInfo(path);

while (directory != null)
{
var gitDir = Path.Combine(directory.FullName, ".git");
if (Directory.Exists(gitDir) || File.Exists(gitDir))
{
return directory.FullName;
}
directory = directory.Parent;
}

return null;
}

/// <summary>
/// Finds the root directory of the Git repository.
/// Alias for GetRepositoryRoot for semantic clarity.
/// </summary>
/// <param name="path">A path within the repository.</param>
/// <returns>The repository root path, or null if not in a Git repository.</returns>
public static string? FindGitRepositoryRoot(string path) => GetRepositoryRoot(path);

/// <summary>
/// Gets the remote origin URL from a Git repository.
/// </summary>
/// <param name="repositoryRoot">The root directory of the Git repository.</param>
/// <returns>The origin URL, or null if not found.</returns>
public static string? GetGitRemoteOriginUrl(string repositoryRoot)
{
var gitDir = Path.Combine(repositoryRoot, ".git");
string configPath;

// Handle both regular .git directory and worktrees (where .git is a file)
if (File.Exists(gitDir))
{
// This is a worktree - .git is a file pointing to the actual git directory
var gitDirContent = File.ReadAllText(gitDir).Trim();
if (gitDirContent.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase))
{
var actualGitDir = gitDirContent.Substring("gitdir:".Length).Trim();
// Navigate from worktree git dir to main repo's config
// Worktree gitdir points to .git/worktrees/<name>, we need parent .git/config
var worktreeDir = new DirectoryInfo(actualGitDir);
if (worktreeDir.Parent?.Name == "worktrees" && worktreeDir.Parent?.Parent != null)
{
configPath = Path.Combine(worktreeDir.Parent.Parent.FullName, "config");
}
else
{
configPath = Path.Combine(actualGitDir, "config");
}
}
else
{
return null;
}
}
else if (Directory.Exists(gitDir))
{
configPath = Path.Combine(gitDir, "config");
}
else
{
return null;
}

if (!File.Exists(configPath))
{
return null;
}

// Parse the git config file to find the origin URL
var lines = File.ReadAllLines(configPath);
var inOriginSection = false;

foreach (var line in lines)
{
var trimmedLine = line.Trim();

if (trimmedLine.StartsWith('['))
{
inOriginSection = trimmedLine.Equals("[remote \"origin\"]", StringComparison.OrdinalIgnoreCase);
continue;
}

if (inOriginSection && trimmedLine.StartsWith("url", StringComparison.OrdinalIgnoreCase))
{
var equalsIndex = trimmedLine.IndexOf('=');
if (equalsIndex > 0)
{
return trimmedLine.Substring(equalsIndex + 1).Trim();
}
}
}

return null;
}

/// <summary>
/// Extracts the repository name from a Git URL.
/// </summary>
/// <param name="gitUrl">The Git URL (SSH or HTTPS format).</param>
/// <returns>The repository name without the .git extension, or null if unable to parse.</returns>
public static string? ExtractRepositoryNameFromGitUrl(string gitUrl)
{
if (string.IsNullOrWhiteSpace(gitUrl))
{
return null;
}

// Handle SSH format: git@github.com:owner/repo.git
if (gitUrl.Contains(':') && gitUrl.Contains('@'))
{
var colonIndex = gitUrl.LastIndexOf(':');
var path = gitUrl.Substring(colonIndex + 1);
return ExtractRepoNameFromPath(path);
}

// Handle HTTPS format: https://github.com/owner/repo.git
if (Uri.TryCreate(gitUrl, UriKind.Absolute, out var uri))
{
var path = uri.AbsolutePath.TrimStart('/');
return ExtractRepoNameFromPath(path);
}

return null;
}

private static string? ExtractRepoNameFromPath(string path)
{
// Remove .git suffix if present
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
{
path = path.Substring(0, path.Length - 4);
}

// Get the last segment (repository name)
var segments = path.Split('/', '\\');
var repoName = segments.LastOrDefault(s => !string.IsNullOrEmpty(s));

return repoName;
}
}
2 changes: 1 addition & 1 deletion src/CodeMedic.Abstractions/Plugins/CommandRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class CommandRegistration
/// <summary>
/// Gets or sets the command handler that will be executed.
/// </summary>
public required Func<string[], IRenderer, Task<int>> Handler { get; init; }
public required Func<HandlerPayload, Task<int>> Handler { get; init; }

/// <summary>
/// Gets or sets example usage strings for help text.
Expand Down
12 changes: 12 additions & 0 deletions src/CodeMedic.Abstractions/Plugins/HandlerPayload.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace CodeMedic.Abstractions.Plugins;

/// <summary>
/// Payload for command handler execution.
/// </summary>
/// <param name="Args">Command-line arguments passed to the handler.</param>
/// <param name="ProjectTitle">Title of the project being operated on</param>
/// <param name="Renderer">Renderer instance for output formatting.</param>
public record struct HandlerPayload(
string[] Args,
string ProjectTitle,
IRenderer Renderer);
11 changes: 10 additions & 1 deletion src/CodeMedic/Commands/ConfigurationCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using CodeMedic.Utilities;
using CodeMedic.Commands;
using CodeMedic.Output;
using CodeMedic.Abstractions.Plugins;

namespace CodeMedic.Commands;

Expand Down Expand Up @@ -98,7 +99,15 @@ private async Task RunCommandsForConfigurationAsync(CodeMedicRunConfiguration co
// Build arguments array to pass the repository path to the command
var commandArgs = new[] { "--path", repoConfig.Path };

await commandPlugin.Handler(commandArgs, formatter);
// setup the handler payload and execute
var payload = new HandlerPayload
{
Args = commandArgs,
Renderer = formatter,
ProjectTitle = $"CodeMedic Report for {repoConfig.Name} - Command: {commandName}"
};

await commandPlugin.Handler(payload);
}

}
Expand Down
10 changes: 9 additions & 1 deletion src/CodeMedic/Commands/RootCommandHandler-MCP.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using CodeMedic.Abstractions.Plugins;
using CodeMedic.Output;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
Expand Down Expand Up @@ -99,7 +100,14 @@ private static async ValueTask<CallToolResult> CallTool(RequestContext<CallToolR
var exitCode = 0;

try {
exitCode = await command.Handler(args, renderer);
// create the handler payload
var payload = new HandlerPayload
{
Args = args,
Renderer = renderer,
ProjectTitle = $"CodeMedic Tool Execution - {command.Name}"
};
exitCode = await command.Handler(payload);
}
catch (Exception ex)
{
Expand Down
66 changes: 58 additions & 8 deletions src/CodeMedic/Commands/RootCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CodeMedic.Abstractions.Plugins;
using CodeMedic.Output;
using CodeMedic.Utilities;
using ModelContextProtocol.Protocol;
Expand Down Expand Up @@ -44,13 +45,6 @@ public static async Task<int> ProcessArguments(string[] args)
return 0;
}


var (flowControl, value) = await HandleConfigCommand(args, version);
if (!flowControl)
{
return value;
}

// Version requested
if (args.Contains("--version") || args.Contains("-v") || args.Contains("version"))
{
Expand All @@ -59,6 +53,13 @@ public static async Task<int> ProcessArguments(string[] args)
return 0;
}

// Handle configuration file command
var (flowControl, value) = await HandleConfigCommand(args, version);
if (!flowControl)
{
return value;
}

// Check if a plugin registered this command
var commandName = args[0];
var commandRegistration = _pluginLoader.GetCommand(commandName);
Expand Down Expand Up @@ -104,7 +105,12 @@ public static async Task<int> ProcessArguments(string[] args)
"markdown" or "md" => new MarkdownRenderer(),
_ => new ConsoleRenderer()
};
return await commandRegistration.Handler(commandArgsList.ToArray(), renderer);
var handlerPayload = new HandlerPayload(
Args: commandArgsList.ToArray(),
ProjectTitle: IdentifyProjectTitle(),
Renderer: renderer
);
return await commandRegistration.Handler(handlerPayload);
}

// Unknown command
Expand All @@ -113,6 +119,50 @@ public static async Task<int> ProcessArguments(string[] args)
return 1;
}

private static string IdentifyProjectTitle()
{

// work through the priorities for identifying project title

// 1. Solution file in current directory or child directories, if and only if there is exactly one solution file
var currentDir = Directory.GetCurrentDirectory();
var solutionFiles = Directory.GetFiles(currentDir, "*.sln", SearchOption.AllDirectories);
if (solutionFiles.Length == 1)
{
return Path.GetFileNameWithoutExtension(solutionFiles[0]);
}


// 2. Project name from csproj / vbproj / fsproj file in current directory or child directories, if and only if there is exactly one project file
var projectFiles = Directory.GetFiles(currentDir, "*.*proj", SearchOption.AllDirectories)
.Where(f => f.EndsWith(".csproj") || f.EndsWith(".vbproj") || f.EndsWith(".fsproj"))
.ToArray();
if (projectFiles.Length == 1)
{
return Path.GetFileNameWithoutExtension(projectFiles[0]);
}

// 3. Git origin remote repository name, if available
var gitDir = GitUtility.FindGitRepositoryRoot(currentDir);
if (gitDir != null)
{
var originUrl = GitUtility.GetGitRemoteOriginUrl(gitDir);
if (!string.IsNullOrEmpty(originUrl))
{
var repoName = GitUtility.ExtractRepositoryNameFromGitUrl(originUrl);
if (!string.IsNullOrEmpty(repoName))
{
return repoName;
}
}
}

// 4. set project title from current directory name
return new DirectoryInfo(currentDir).Name;


}

private static async Task<(bool flowControl, int value)> HandleConfigCommand(string[] args, string version)
{

Expand Down
5 changes: 5 additions & 0 deletions src/CodeMedic/Models/Report/ReportDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ public class ReportDocument
/// </summary>
public string Title { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the project name associated with the report.
/// </summary>
public string ProjectName { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the report metadata.
/// </summary>
Expand Down
13 changes: 11 additions & 2 deletions src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,12 @@ public async Task<object> AnalyzeAsync(string repositoryPath, CancellationToken
];
}

private async Task<int> ExecuteBomCommandAsync(string[] args, IRenderer renderer)
private async Task<int> ExecuteBomCommandAsync(HandlerPayload payload)
{

var args = payload.Args;
var renderer = payload.Renderer;

try
{
// Parse arguments (target path only)
Expand All @@ -103,7 +107,7 @@ private async Task<int> ExecuteBomCommandAsync(string[] args, IRenderer renderer
}

// Render banner and header
renderer.RenderBanner();
renderer.RenderBanner($"Report for project {payload.ProjectTitle}");
renderer.RenderSectionHeader("Bill of Materials (BOM)");

// Run analysis
Expand All @@ -117,6 +121,11 @@ await renderer.RenderWaitAsync($"Running {AnalysisDescription}...", async () =>

reportDocument = await AnalyzeAsync(repositoryPath);

if (reportDocument is ReportDocument rd)
{
rd.ProjectName = payload.ProjectTitle;
}

// Render report
renderer.RenderReport(reportDocument);

Expand Down
Loading
Loading