Files
gitcommiteditor/Services/GitDiagnosticService.cs

313 lines
10 KiB
C#
Executable File

using System.Text;
using MarketAlly.AIPlugin;
using MarketAlly.AIPlugin.Conversation;
using MarketAlly.GitCommitEditor.Options;
using MarketAlly.GitCommitEditor.Plugins;
using MarketAlly.GitCommitEditor.Resources;
namespace MarketAlly.GitCommitEditor.Services;
/// <summary>
/// Result of a git diagnosis operation including cost information
/// </summary>
public sealed record DiagnosisOperationResult(
GitDiagnosisResult Diagnosis,
int InputTokens = 0,
int OutputTokens = 0,
decimal EstimatedCost = 0);
/// <summary>
/// Service for diagnosing git issues using AI
/// </summary>
public interface IGitDiagnosticService
{
/// <summary>
/// Diagnose a git issue based on the current repository state
/// </summary>
Task<GitDiagnosisResult> DiagnoseAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default);
/// <summary>
/// Diagnose a git issue and return cost information
/// </summary>
Task<DiagnosisOperationResult> DiagnoseWithCostAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default);
}
/// <summary>
/// AI-powered git diagnostic service using MarketAlly.AIPlugin
/// </summary>
public sealed class GitDiagnosticService : IGitDiagnosticService, IDisposable
{
private const string OperationType = "GitDiagnosis";
private readonly AiOptions _options;
private readonly AIConversation _conversation;
private readonly ICostTrackingService? _costTracker;
public GitDiagnosticService(AiOptions options, ICostTrackingService? costTracker = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_costTracker = costTracker;
var provider = ParseProvider(options.Provider);
_conversation = AIConversationBuilder.Create()
.UseProvider(provider, options.ApiKey)
.UseModel(options.Model)
.WithSystemPrompt(BuildSystemPrompt())
.WithTemperature(0.2) // Low temperature for consistent diagnostic output
.WithMaxTokens(options.MaxTokens)
.WithToolExecutionLimit(3)
.RegisterPlugin<ReturnGitDiagnosisPlugin>()
.Build();
}
private static AIProvider ParseProvider(string? providerName)
{
return providerName?.ToLowerInvariant() switch
{
"claude" or "anthropic" => AIProvider.Claude,
"openai" or "gpt" => AIProvider.OpenAI,
"gemini" or "google" => AIProvider.Gemini,
"mistral" => AIProvider.Mistral,
"qwen" or "alibaba" => AIProvider.Qwen,
_ => AIProvider.Claude
};
}
private static string BuildSystemPrompt()
{
return """
You are a git expert who diagnoses and fixes git issues. When given git status, log, and error information, you analyze the problem and provide a clear diagnosis with the exact commands to fix it.
COMMON ISSUES YOU CAN DIAGNOSE:
- Diverged branches (local and remote have different commits)
- Unrelated histories (refusing to merge unrelated histories)
- Merge conflicts
- Detached HEAD state
- Failed rebases
- Uncommitted changes blocking operations
- Authentication issues
- Remote tracking issues
- Corrupt repository state
RULES:
1. Always provide the exact command(s) needed to fix the issue
2. Warn about any potential data loss
3. Explain WHY the issue happened so the user can avoid it
4. If multiple solutions exist, recommend the safest one
5. Set appropriate risk level: low (safe), medium (some risk), high (potential data loss)
When you receive git information, analyze it and call ReturnGitDiagnosis with your findings.
""";
}
public async Task<GitDiagnosisResult> DiagnoseAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default)
{
var result = await DiagnoseWithCostAsync(repoPath, errorMessage, ct);
return result.Diagnosis;
}
public async Task<DiagnosisOperationResult> DiagnoseWithCostAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default)
{
var gitInfo = await GatherGitInfoAsync(repoPath);
var prompt = BuildPrompt(gitInfo, errorMessage);
var response = await _conversation.SendForStructuredOutputAsync<GitDiagnosisResult>(
prompt,
"ReturnGitDiagnosis",
ct);
// Extract cost information
var inputTokens = response.InputTokens;
var outputTokens = response.OutputTokens;
var cost = response.EstimatedCost ?? 0m;
// Record cost if tracking service is available
_costTracker?.RecordOperation(OperationType, inputTokens, outputTokens, cost);
if (response.StructuredOutput is GitDiagnosisResult result)
{
return new DiagnosisOperationResult(result, inputTokens, outputTokens, cost);
}
// Fallback if structured output failed
var fallback = new GitDiagnosisResult
{
Problem = "Unable to diagnose the issue",
Cause = response.FinalMessage ?? Str.Service_AiAnalysisFailed,
FixCommand = "git status",
Warning = "Please review the git state manually",
RiskLevel = RiskLevel.Low
};
return new DiagnosisOperationResult(fallback, inputTokens, outputTokens, cost);
}
private async Task<GitInfo> GatherGitInfoAsync(string repoPath)
{
var info = new GitInfo();
try
{
// Get git status
info.Status = await RunGitCommandAsync(repoPath, "status");
// Get recent log
info.Log = await RunGitCommandAsync(repoPath, "log --oneline -10");
// Get branch info
info.BranchInfo = await RunGitCommandAsync(repoPath, "branch -vv");
// Get remote info
info.RemoteInfo = await RunGitCommandAsync(repoPath, "remote -v");
// Check for ongoing operations
info.OngoingOps = await CheckOngoingOperationsAsync(repoPath);
}
catch (Exception ex)
{
info.Error = ex.Message;
}
return info;
}
private static async Task<string> RunGitCommandAsync(string repoPath, string arguments)
{
try
{
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "git",
Arguments = arguments,
WorkingDirectory = repoPath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = System.Diagnostics.Process.Start(startInfo);
if (process == null) return "[Failed to start git]";
var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return string.IsNullOrEmpty(error) ? output : $"{output}\n{error}";
}
catch (Exception ex)
{
return $"[Error: {ex.Message}]";
}
}
private static async Task<string> CheckOngoingOperationsAsync(string repoPath)
{
var ops = new List<string>();
var gitDir = Path.Combine(repoPath, ".git");
if (!Directory.Exists(gitDir))
{
// Might be a worktree, try to find the git dir
var gitFile = Path.Combine(repoPath, ".git");
if (File.Exists(gitFile))
{
var content = await File.ReadAllTextAsync(gitFile);
if (content.StartsWith("gitdir:"))
{
gitDir = content.Substring(7).Trim();
}
}
}
if (Directory.Exists(gitDir))
{
if (Directory.Exists(Path.Combine(gitDir, "rebase-merge")) ||
Directory.Exists(Path.Combine(gitDir, "rebase-apply")))
{
ops.Add("REBASE IN PROGRESS");
}
if (File.Exists(Path.Combine(gitDir, "MERGE_HEAD")))
{
ops.Add("MERGE IN PROGRESS");
}
if (File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD")))
{
ops.Add("CHERRY-PICK IN PROGRESS");
}
if (File.Exists(Path.Combine(gitDir, "REVERT_HEAD")))
{
ops.Add("REVERT IN PROGRESS");
}
if (File.Exists(Path.Combine(gitDir, "BISECT_LOG")))
{
ops.Add("BISECT IN PROGRESS");
}
}
return ops.Count > 0 ? string.Join(", ", ops) : "None";
}
private static string BuildPrompt(GitInfo info, string? errorMessage)
{
var sb = new StringBuilder();
sb.AppendLine("Please diagnose this git issue and provide the fix:");
sb.AppendLine();
if (!string.IsNullOrEmpty(errorMessage))
{
sb.AppendLine("ERROR MESSAGE:");
sb.AppendLine(errorMessage);
sb.AppendLine();
}
sb.AppendLine("GIT STATUS:");
sb.AppendLine(info.Status);
sb.AppendLine();
sb.AppendLine("RECENT COMMITS:");
sb.AppendLine(info.Log);
sb.AppendLine();
sb.AppendLine("BRANCH INFO:");
sb.AppendLine(info.BranchInfo);
sb.AppendLine();
sb.AppendLine("REMOTE INFO:");
sb.AppendLine(info.RemoteInfo);
sb.AppendLine();
sb.AppendLine($"ONGOING OPERATIONS: {info.OngoingOps}");
if (!string.IsNullOrEmpty(info.Error))
{
sb.AppendLine();
sb.AppendLine($"ADDITIONAL ERROR: {info.Error}");
}
sb.AppendLine();
sb.AppendLine("Call ReturnGitDiagnosis with the problem, cause, fix command, any warnings, and risk level.");
return sb.ToString();
}
public void Dispose()
{
// AIConversation handles its own disposal
}
private class GitInfo
{
public string Status { get; set; } = string.Empty;
public string Log { get; set; } = string.Empty;
public string BranchInfo { get; set; } = string.Empty;
public string RemoteInfo { get; set; } = string.Empty;
public string OngoingOps { get; set; } = string.Empty;
public string Error { get; set; } = string.Empty;
}
}