commit fc4bcf7b8c62cd62fbd235c2e9950e3a4cec2478 Author: David Friedel Date: Sun Dec 28 09:49:58 2025 +0000 Initial commit - MarketAlly.GitCommitEditor library diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b6892d --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ +[Oo]ut/ + +# Visual Studio +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates +*.rsuser +*.userprefs +launchSettings.json + +# Rider +.idea/ + +# VS Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ +*.nuget.props +*.nuget.targets +project.lock.json +project.fragment.lock.json +artifacts/ + +# MSBuild +*.log +*.binlog +msbuild.binlog + +# Test results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +TestResult.xml +nunit-*.xml +coverage/ +*.coverage +*.coveragexml + +# macOS +.DS_Store +*.dmg +._* + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Secrets +appsettings.*.json +!appsettings.json +secrets.json +*.pfx +*.key diff --git a/API_Reference.md b/API_Reference.md new file mode 100644 index 0000000..1844ddb --- /dev/null +++ b/API_Reference.md @@ -0,0 +1,1038 @@ +# MarketAlly.GitCommitEditor API Reference + +Complete API documentation for developers and AI assistants. + +--- + +## Table of Contents + +- [Services](#services) + - [IGitMessageImproverService](#igitmessageimproverservice) + - [IRepositoryManager](#irepositorymanager) + - [ICommitAnalysisService](#icommitanalysisservice) + - [ISuggestionService](#isuggestionservice) + - [ICommitRewriteService](#icommitrewriteservice) + - [IGitPushService](#igitpushservice) + - [IHistoryHealthService](#ihistoryhealthservice) + - [ICommitMessageAnalyzer](#icommitmessageanalyzer) + - [ICleanupExecutor](#icleanupexecutor) +- [Models](#models) + - [ManagedRepo](#managedrepo) + - [CommitAnalysis](#commitanalysis) + - [MessageQualityScore](#messagequalityscore) + - [QualityIssue](#qualityissue) + - [CommitContext](#commitcontext) + - [RewriteOperation](#rewriteoperation) + - [RewriteSafetyInfo](#rewritesafetyinfo) + - [BatchResult](#batchresult) + - [BatchRewriteResult](#batchrewriteresult) + - [BranchInfo](#branchinfo) + - [Result Types](#result-types) +- [History Health Models](#history-health-models) + - [HistoryHealthReport](#historyhealthreport) + - [HealthScore](#healthscore) + - [HealthIssue](#healthissue) + - [CleanupOperation](#cleanupoperation) + - [CleanupSuggestions](#cleanupsuggestions) +- [Options](#options) + - [GitImproverOptions](#gitimproveroptions) + - [CommitMessageRules](#commitmessagerules) + - [AiOptions](#aioptions) + - [HistoryAnalysisOptions](#historyanalysisoptions) + - [CleanupExecutionOptions](#cleanupexecutionoptions) +- [Enumerations](#enumerations) +- [Extensions](#extensions) +- [Usage Patterns](#usage-patterns) +- [Error Handling](#error-handling) + +--- + +## Services + +### IGitMessageImproverService + +The main facade interface that composes all functionality. This is the primary entry point for most use cases. + +**Implements:** `IRepositoryManager`, `ICommitAnalysisService`, `ISuggestionService`, `ICommitRewriteService`, `IGitPushService`, `IHistoryHealthService`, `IDisposable` + +```csharp +public interface IGitMessageImproverService : IDisposable +{ + // State Management + Task LoadStateAsync(CancellationToken ct = default); + string GenerateSummaryReport(); + + // All methods from composed interfaces (see below) +} +``` + +#### Factory Method + +```csharp +// Create service without DI container +public static Task CreateAsync(GitImproverOptions options); +``` + +#### Additional Methods on Implementation + +```csharp +// Branch management +Task CheckoutBranchAsync(ManagedRepo repo, string branchName); +IEnumerable GetBackupBranches(string repoPath); +bool DeleteBranch(string repoPath, string branchName); +int DeleteAllBackupBranches(string repoPath); + +// Safety checks +RewriteSafetyInfo GetRewriteSafetyInfo(string repoPath, IEnumerable commits); + +// Batch rewrite with safety +Task ExecuteBatchRewriteAsync( + string repoPath, + IEnumerable commits, + bool createBackup = true, + IProgress<(int Current, int Total, string CommitHash)>? progress = null, + CancellationToken ct = default); +``` + +--- + +### IRepositoryManager + +Manages git repository discovery and registration. + +```csharp +public interface IRepositoryManager +{ + /// Gets all registered repositories. + IReadOnlyList Repos { get; } + + /// Scans WorkspaceRoot for git repos and registers new ones. + Task> ScanAndRegisterReposAsync(CancellationToken ct = default); + + /// Manually register a repository by path. + Task RegisterRepoAsync(string repoPath); + + /// Unregister a repository by ID or path. + Task UnregisterRepoAsync(string repoIdOrPath); + + /// Get all branches (local and remote) for a repository. + IEnumerable GetBranches(string repoPath); +} +``` + +--- + +### ICommitAnalysisService + +Provides commit message quality analysis. + +```csharp +public interface ICommitAnalysisService +{ + /// Analyze commits across all registered repositories. + /// If true, only return commits with quality issues. + /// Reports (RepoName, CommitCount) as repos are processed. + Task> AnalyzeAllReposAsync( + bool onlyNeedsImprovement = true, + IProgress<(string Repo, int Processed)>? progress = null, + CancellationToken ct = default); + + /// Analyze commits in a single repository. + IEnumerable AnalyzeRepo(ManagedRepo repo); + + /// Analyze a specific commit by hash. + CommitAnalysis AnalyzeCommit(string repoPath, string commitHash); + + /// Update and persist repo analysis statistics. + Task UpdateRepoAnalysisAsync( + ManagedRepo repo, + int totalCommits, + int commitsNeedingImprovement, + CancellationToken ct = default); +} +``` + +--- + +### ISuggestionService + +Generates AI-powered commit message suggestions. + +```csharp +public interface ISuggestionService +{ + /// Generate AI suggestions for multiple commits. + /// Result with success/failure counts and individual failures. + Task GenerateSuggestionsAsync( + IEnumerable analyses, + IProgress? progress = null, + CancellationToken ct = default); + + /// Generate AI suggestion for a single commit. + Task GenerateSuggestionAsync( + CommitAnalysis analysis, + CancellationToken ct = default); +} +``` + +**Throws:** `ApiKeyNotConfiguredException` if AI provider API key is not set. + +--- + +### ICommitRewriteService + +Handles commit message rewriting operations. + +```csharp +public interface ICommitRewriteService +{ + /// History of all rewrite operations. + IReadOnlyList History { get; } + + /// Create RewriteOperation objects for commits with suggestions. + IReadOnlyList PreviewChanges(IEnumerable analyses); + + /// Apply commit message changes. + /// If true, validate but don't modify commits. + Task ApplyChangesAsync( + IEnumerable operations, + bool dryRun = true, + IProgress<(int Processed, int Total)>? progress = null, + CancellationToken ct = default); + + /// Apply suggested message for a single commit. + Task ApplyChangeAsync( + CommitAnalysis analysis, + CancellationToken ct = default); + + /// Undo a commit amend by resetting to original commit. + bool UndoCommitAmend(string repoPath, string originalCommitHash); +} +``` + +--- + +### IGitPushService + +Handles git push operations and remote tracking. + +```csharp +public interface IGitPushService +{ + /// Check if a commit exists on the remote tracking branch. + bool IsCommitPushed(string repoPath, string commitHash); + + /// Get tracking information for current branch. + TrackingInfo GetTrackingInfo(string repoPath); + + /// Push current branch to remote. + GitPushResult Push(string repoPath); + + /// Force push current branch to remote (overwrites remote history). + GitPushResult ForcePush(string repoPath); +} +``` + +--- + +### IHistoryHealthService + +Service for analyzing and reporting on git repository history health. + +```csharp +public interface IHistoryHealthService +{ + /// Analyzes repository history health and generates a comprehensive report. + Task AnalyzeHistoryHealthAsync( + string repoPath, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// Analyzes history health for a managed repository. + Task AnalyzeHistoryHealthAsync( + ManagedRepo repo, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// Exports a health report to the specified format (Json, Markdown, Html, Console). + Task ExportHealthReportAsync( + HistoryHealthReport report, + ReportFormat format, + CancellationToken ct = default); + + /// Exports a health report to a file. + Task ExportHealthReportToFileAsync( + HistoryHealthReport report, + ReportFormat format, + string outputPath, + CancellationToken ct = default); + + /// Executes a single cleanup operation. + Task ExecuteCleanupAsync( + ManagedRepo repo, + CleanupOperation operation, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// Executes all cleanup operations from suggestions. + Task ExecuteAllCleanupsAsync( + ManagedRepo repo, + CleanupSuggestions suggestions, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// Creates a backup branch before cleanup operations. + Task CreateBackupBranchAsync( + ManagedRepo repo, + string? branchName = null, + CancellationToken ct = default); +} +``` + +--- + +### ICommitMessageAnalyzer + +Analyzes commit message quality against configured rules. + +```csharp +public interface ICommitMessageAnalyzer +{ + /// Analyze message without change context. + MessageQualityScore Analyze(string message); + + /// Analyze message with context about actual changes. + /// Enables detection of vague messages for significant changes. + MessageQualityScore Analyze(string message, CommitContext context); +} +``` + +--- + +### ICleanupExecutor + +Executes cleanup operations on git repositories. + +```csharp +public interface ICleanupExecutor +{ + /// Executes a single cleanup operation. + Task ExecuteAsync( + ManagedRepo repo, + CleanupOperation operation, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// Executes multiple cleanup operations in sequence. + Task ExecuteBatchAsync( + ManagedRepo repo, + IEnumerable operations, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// Previews what a cleanup operation will do without executing it. + Task PreviewAsync( + ManagedRepo repo, + CleanupOperation operation, + CancellationToken ct = default); + + /// Creates a backup branch before cleanup operations. + Task CreateBackupBranchAsync( + ManagedRepo repo, + string? branchName = null, + CancellationToken ct = default); +} +``` + +--- + +## Models + +### ManagedRepo + +Represents a registered git repository. + +```csharp +public sealed class ManagedRepo +{ + public string Id { get; } // GUID + public string Name { get; set; } // Directory name + public string Path { get; set; } // Full path + public string? RemoteUrl { get; set; } // Origin URL + public string? CurrentBranch { get; set; } + public DateTimeOffset? LastScannedAt { get; set; } + public DateTimeOffset? LastAnalyzedAt { get; set; } + public int TotalCommits { get; set; } + public int CommitsNeedingImprovement { get; set; } +} +``` + +--- + +### CommitAnalysis + +Complete analysis of a single commit. + +```csharp +public sealed class CommitAnalysis +{ + public string RepoId { get; } + public string RepoName { get; } + public string RepoPath { get; } + public string CommitHash { get; } + public string ShortHash { get; } // First 7 characters + public string OriginalMessage { get; } + public string? SuggestedMessage { get; set; } // Populated by AI + public DateTimeOffset CommitDate { get; } + public string Author { get; } + public string AuthorEmail { get; } + public MessageQualityScore Quality { get; } + public IReadOnlyList FilesChanged { get; } + public string? DiffSummary { get; } + public IReadOnlyDictionary FileDiffs { get; } // Path -> Diff + public int LinesAdded { get; } + public int LinesDeleted { get; } + public bool IsLatestCommit { get; } + public AnalysisStatus Status { get; set; } // Pending, Analyzed, Applied, Failed, Skipped +} +``` + +--- + +### MessageQualityScore + +Quality assessment with detailed issues. + +```csharp +public sealed class MessageQualityScore +{ + public int OverallScore { get; } // 0-100 + public IReadOnlyList Issues { get; } + public bool NeedsImprovement { get; } // Score < 70 or has Error severity +} +``` + +--- + +### QualityIssue + +Individual quality problem found in a message. + +```csharp +public sealed class QualityIssue +{ + public required string Code { get; init; } // e.g., "SUBJECT_TOO_SHORT" + public required string Message { get; init; } // Human-readable description + public required IssueSeverity Severity { get; init; } + public int ScoreImpact { get; init; } // Points deducted +} +``` + +**Issue Codes:** +- `SUBJECT_TOO_SHORT` - Subject line below minimum length +- `SUBJECT_TOO_LONG` - Subject line exceeds maximum length +- `BODY_TOO_SHORT` - Body below minimum length (if required) +- `MISSING_CONVENTIONAL_TYPE` - Missing conventional commit type prefix +- `BANNED_PHRASE` - Contains banned/vague phrase +- `MISSING_ISSUE_REFERENCE` - Missing issue/ticket reference (if required) +- `VAGUE_FOR_CHANGES` - Message too vague for significant code changes + +--- + +### CommitContext + +Context about commit changes for smarter analysis. + +```csharp +public sealed class CommitContext +{ + public int FilesChanged { get; init; } + public int LinesAdded { get; init; } + public int LinesDeleted { get; init; } + public IReadOnlyList FileNames { get; init; } + + public int TotalLinesChanged { get; } // Added + Deleted + public bool HasSignificantChanges { get; } // Files > 0 or Lines > 0 + + public static CommitContext Empty { get; } +} +``` + +--- + +### RewriteOperation + +Represents a commit rewrite operation. + +```csharp +public sealed class RewriteOperation +{ + public string Id { get; } // GUID + public string RepoId { get; set; } + public string RepoPath { get; set; } + public string CommitHash { get; set; } + public string? NewCommitHash { get; set; } // After rewrite + public string OriginalMessage { get; set; } + public string NewMessage { get; set; } + public bool IsLatestCommit { get; set; } + public OperationStatus Status { get; set; } // Pending, Applied, Failed, Undone + public string? ErrorMessage { get; set; } + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset? AppliedAt { get; set; } +} +``` + +--- + +### RewriteSafetyInfo + +Safety information for batch rewrite operations. + +```csharp +public sealed class RewriteSafetyInfo +{ + public bool HasUncommittedChanges { get; init; } + public bool HasPushedCommits { get; init; } + public int PushedCommitCount { get; init; } + public int LocalOnlyCommitCount { get; init; } + public int TotalCommitCount { get; init; } + public bool HasRemoteTracking { get; init; } + public string? RemoteTrackingBranch { get; init; } + public int? AheadOfRemote { get; init; } + public int? BehindRemote { get; init; } + public bool BackupBranchCreated { get; init; } + public string? BackupBranchName { get; init; } + + // Computed properties + public bool CanProceedSafely { get; } // !HasUncommittedChanges && !HasPushedCommits + public bool CanProceedWithWarnings { get; } // !HasUncommittedChanges + + // Methods + public IReadOnlyList GetWarnings(); + public string GetSummary(); +} +``` + +--- + +### BatchResult + +Result of batch operations (ApplyChangesAsync). + +```csharp +public sealed class BatchResult +{ + public int TotalProcessed { get; init; } + public int Successful { get; init; } + public int Failed { get; init; } + public int Skipped { get; init; } + public IReadOnlyList Operations { get; init; } +} +``` + +--- + +### BatchRewriteResult + +Result of ExecuteBatchRewriteAsync. + +```csharp +public sealed class BatchRewriteResult +{ + public bool Success { get; init; } + public int SuccessCount { get; init; } + public int FailedCount { get; init; } + public int SkippedCount { get; init; } + public string? ErrorMessage { get; init; } + public bool RequiresForcePush { get; init; } + public string? BackupBranchName { get; init; } + public IReadOnlyList Operations { get; init; } + + public static BatchRewriteResult Failure(string error); +} +``` + +--- + +### BranchInfo + +Information about a git branch. + +```csharp +public sealed class BranchInfo +{ + public string Name { get; init; } // e.g., "main" + public string FullName { get; init; } // e.g., "refs/heads/main" + public bool IsRemote { get; init; } + public bool IsCurrentHead { get; init; } + public string? LastCommitSha { get; init; } + public DateTimeOffset? LastCommitDate { get; init; } + public string? RemoteName { get; init; } // e.g., "origin" +} +``` + +--- + +### Result Types + +```csharp +// Push operation result +public sealed record GitPushResult(bool Success, string Message) +{ + public static GitPushResult Ok(string message = "Push successful"); + public static GitPushResult Fail(string message); +} + +// Branch tracking information +public sealed record TrackingInfo(string? UpstreamBranch, int? AheadBy, int? BehindBy) +{ + public static TrackingInfo None { get; } + public bool HasUpstream { get; } + public bool IsInSync { get; } // AheadBy == 0 && BehindBy == 0 +} + +// AI suggestion result +public sealed record SuggestionResult( + CommitAnalysis? Analysis, + string? Suggestion, + string? ErrorMessage = null, + string? RawResponse = null, + bool ReturnedOriginal = false, + int InputTokens = 0, + int OutputTokens = 0, + decimal EstimatedCost = 0) +{ + public bool Success { get; } + public int TotalTokens { get; } + + public static SuggestionResult Ok(CommitAnalysis analysis, string suggestion, ...); + public static SuggestionResult Succeeded(string suggestion, ...); + public static SuggestionResult Fail(CommitAnalysis analysis, string error); + public static SuggestionResult Failed(string error, string? rawResponse = null); +} + +// Batch suggestion result +public sealed class BatchSuggestionResult +{ + public IReadOnlyList Analyses { get; init; } + public int SuccessCount { get; init; } + public int FailedCount { get; init; } + public IReadOnlyList Failures { get; init; } +} +``` + +--- + +## History Health Models + +### HistoryHealthReport + +Complete health report with scoring, issues, and recommendations. + +```csharp +public sealed class HistoryHealthReport +{ + public required string RepoId { get; init; } + public required string RepoName { get; init; } + public required string RepoPath { get; init; } + public required string CurrentBranch { get; init; } + public DateTimeOffset GeneratedAt { get; init; } + public int CommitsAnalyzed { get; init; } + + // Summary score + public required HealthScore Score { get; init; } + + // Detailed metrics + public required DuplicateCommitMetrics DuplicateMetrics { get; init; } + public required MergeCommitMetrics MergeMetrics { get; init; } + public required BranchComplexityMetrics BranchMetrics { get; init; } + public required MessageQualityDistribution MessageDistribution { get; init; } + public required AuthorshipMetrics AuthorshipMetrics { get; init; } + + // Issues and recommendations + public IReadOnlyList Issues { get; init; } + public IReadOnlyList Recommendations { get; init; } + + // Cleanup opportunities + public CleanupSuggestions? CleanupSuggestions { get; init; } + + // Convenience properties + public int CriticalIssueCount { get; } + public int ErrorCount { get; } + public int WarningCount { get; } +} +``` + +--- + +### HealthScore + +```csharp +public sealed class HealthScore +{ + public int OverallScore { get; init; } // 0-100 + public HealthGrade Grade { get; init; } // Excellent, Good, Fair, Poor, Critical + public int MessageQualityScore { get; init; } + public int DuplicateScore { get; init; } + public int MergeHealthScore { get; init; } + public int BranchComplexityScore { get; init; } +} +``` + +--- + +### HealthIssue + +```csharp +public sealed class HealthIssue +{ + public required string Code { get; init; } + public required string Category { get; init; } + public required HealthIssueSeverity Severity { get; init; } // Info, Warning, Error, Critical + public required string Title { get; init; } + public required string Description { get; init; } + public int ImpactScore { get; init; } + public IReadOnlyList AffectedCommits { get; init; } +} +``` + +--- + +### CleanupOperation + +```csharp +public sealed class CleanupOperation +{ + public required string Id { get; init; } + public required string Title { get; init; } + public required string Description { get; init; } + public required CleanupType Type { get; init; } + public required CleanupAutomationLevel AutomationLevel { get; init; } + public EstimatedEffort Effort { get; init; } + public RiskLevel Risk { get; init; } + public int ExpectedScoreImprovement { get; init; } + public IReadOnlyList AffectedCommits { get; init; } + public string? GitCommand { get; init; } + public CleanupOperationStatus Status { get; set; } +} +``` + +--- + +### CleanupSuggestions + +```csharp +public sealed class CleanupSuggestions +{ + public IReadOnlyList AutomatedOperations { get; init; } + public IReadOnlyList SemiAutomatedOperations { get; init; } + public IReadOnlyList ManualOperations { get; init; } + + public int TotalOperations { get; } + public int TotalExpectedImprovement { get; } +} +``` + +--- + +## Options + +### GitImproverOptions + +Main configuration object. + +```csharp +public sealed class GitImproverOptions +{ + public string WorkspaceRoot { get; set; } // Required + public string StateFilePath { get; set; } // Default: "git-improver-state.json" + public CommitMessageRules Rules { get; set; } + public AiOptions Ai { get; set; } + public int MaxCommitsPerRepo { get; set; } // Default: 100 + public DateTimeOffset? AnalyzeSince { get; set; } + public string[] ExcludedAuthors { get; set; } // Default: [] + + public void ValidateAndThrow(); +} +``` + +--- + +### CommitMessageRules + +Quality rules configuration. + +```csharp +public sealed class CommitMessageRules +{ + public int MinSubjectLength { get; set; } // Default: 10 + public int MaxSubjectLength { get; set; } // Default: 72 + public int MinBodyLength { get; set; } // Default: 0 + public bool RequireConventionalCommit { get; set; } // Default: false + public bool RequireIssueReference { get; set; } // Default: false + public string[] BannedPhrases { get; set; } // Default: common vague words + public string[] ConventionalTypes { get; set; } // Default: feat, fix, docs, etc. +} +``` + +--- + +### AiOptions + +AI provider configuration. + +```csharp +public sealed class AiOptions +{ + [JsonIgnore] + public string ApiKey { get; set; } // Not persisted + public string Provider { get; set; } // Default: "Claude" + public string Model { get; set; } // Default: "claude-sonnet-4-20250514" + public bool IncludeDiffContext { get; set; } // Default: true + public int MaxDiffLines { get; set; } // Default: 200 + public int MaxTokens { get; set; } // Default: 500 + public int RateLimitDelayMs { get; set; } // Default: 500 + + public static AIProvider ParseProvider(string? provider); +} +``` + +--- + +### HistoryAnalysisOptions + +```csharp +public sealed class HistoryAnalysisOptions +{ + public AnalysisDepth Depth { get; set; } // Quick, Standard, Deep, Full + public DateTimeOffset? Since { get; set; } + public string[]? IncludeBranches { get; set; } + public string[]? ExcludeAuthors { get; set; } + public bool AnalyzeMerges { get; set; } // Default: true + public bool DetectDuplicates { get; set; } // Default: true +} +``` + +--- + +### CleanupExecutionOptions + +```csharp +public sealed record CleanupExecutionOptions +{ + public bool CreateBackup { get; init; } // Default: true + public string? BackupBranchName { get; init; } + public bool AllowPushedCommits { get; init; } // Default: false + public bool AutoForcePush { get; init; } // Default: false + public bool UseAiForMessages { get; init; } // Default: true +} +``` + +--- + +## Enumerations + +### Analysis & Quality + +```csharp +public enum AnalysisStatus { Pending, Analyzed, Applied, Failed, Skipped } +public enum IssueSeverity { Info, Warning, Error } +public enum OperationStatus { Pending, Applied, Failed, Undone } +``` + +### Health Grades + +```csharp +public enum HealthGrade +{ + Excellent, // 90-100: Best practices followed + Good, // 70-89: Minor issues, generally healthy + Fair, // 50-69: Noticeable issues, needs attention + Poor, // 30-49: Significant problems, cleanup recommended + Critical // 0-29: Severe issues, immediate action required +} + +public enum HealthIssueSeverity { Info, Warning, Error, Critical } +``` + +### Cleanup + +```csharp +public enum CleanupType +{ + SquashDuplicates, + RewordMessages, + SquashMerges, + RebaseLinearize, + ArchiveBranches, + FixAuthorship, + ConsolidateMerges +} + +public enum CleanupAutomationLevel +{ + FullyAutomated, // Can run with one click + SemiAutomated, // Requires user review/approval + Manual // Requires manual git commands +} + +public enum CleanupOperationStatus { Suggested, Approved, InProgress, Completed, Failed, Skipped } +public enum RiskLevel { None, Low, Medium, High, VeryHigh } +public enum EstimatedEffort { Minimal, Low, Medium, High, VeryHigh } +``` + +### Analysis Depth & Format + +```csharp +public enum AnalysisDepth +{ + Quick, // Sample 200 commits, basic metrics + Standard, // 1000 commits, all metrics + Deep, // 5000 commits, comprehensive + Full // All commits (slow for large repos) +} + +public enum ReportFormat { Json, Markdown, Html, Console } +``` + +--- + +## Extensions + +### ServiceCollectionExtensions + +```csharp +public static class ServiceCollectionExtensions +{ + /// + /// Registers all GitMessageImprover services with the DI container. + /// + public static IServiceCollection AddGitMessageImprover( + this IServiceCollection services, + Action configureOptions); +} +``` + +**Registered Services:** +- `GitImproverOptions` (Singleton) +- `CommitMessageRules` (Singleton) +- `AiOptions` (Singleton) +- `IStateRepository` (Singleton) +- `ICommitMessageAnalyzer` (Singleton) +- `IGitOperationsService` (Singleton) +- `ICommitMessageRewriter` (Singleton) +- `IGitMessageImproverService` (Singleton) + +--- + +## Usage Patterns + +### Pattern 1: CLI Tool + +```csharp +var options = new GitImproverOptions { WorkspaceRoot = args[0] }; +await using var service = await GitMessageImproverService.CreateAsync(options); + +var repos = await service.ScanAndRegisterReposAsync(); +var analyses = await service.AnalyzeAllReposAsync(onlyNeedsImprovement: true); + +foreach (var analysis in analyses) +{ + Console.WriteLine($"[{analysis.Quality.OverallScore}] {analysis.ShortHash}: {analysis.OriginalMessage}"); +} +``` + +### Pattern 2: MAUI/WPF App with DI + +```csharp +// In MauiProgram.cs +services.AddGitMessageImprover(opt => +{ + opt.WorkspaceRoot = Preferences.Get("workspace", ""); + opt.Ai.ApiKey = SecureStorage.GetAsync("api_key").Result ?? ""; +}); + +// In ViewModel +public class CommitViewModel(IGitMessageImproverService service) +{ + public async Task LoadAsync() + { + await service.LoadStateAsync(); + Repos = new ObservableCollection(service.Repos); + } +} +``` + +### Pattern 3: Focused Interface Usage + +```csharp +// Only need push operations - inject the focused interface +public class PushHandler(IGitPushService pushService) +{ + public bool SafePush(string repoPath, string commitHash) + { + if (pushService.IsCommitPushed(repoPath, commitHash)) + return true; + + return pushService.Push(repoPath).Success; + } +} +``` + +### Pattern 4: Health Analysis Pipeline + +```csharp +// Analyze -> Review -> Cleanup +var report = await service.AnalyzeHistoryHealthAsync(repoPath); + +if (report.Score.Grade <= HealthGrade.Fair) +{ + // Export for review + var markdown = await service.ExportHealthReportAsync(report, ReportFormat.Markdown); + await File.WriteAllTextAsync("health-report.md", markdown); + + // Execute safe automated cleanups + if (report.CleanupSuggestions?.AutomatedOperations.Any() == true) + { + var result = await service.ExecuteAllCleanupsAsync( + repo, + report.CleanupSuggestions, + new CleanupExecutionOptions { CreateBackup = true, AllowPushedCommits = false }); + } +} +``` + +--- + +## Error Handling + +| Exception | Cause | Resolution | +|-----------|-------|------------| +| `ApiKeyNotConfiguredException` | AI API key not configured | Set `AiOptions.ApiKey` | +| `ValidationException` | Invalid `GitImproverOptions` | Check required fields | +| `InvalidOperationException` | No suggested message for commit | Generate suggestion first | +| `ArgumentException` | Commit or repository not found | Verify paths and hashes | + +--- + +## Thread Safety Notes + +- Repository cache uses `ConcurrentDictionary` with LRU eviction +- All async methods support `CancellationToken` +- State operations are serialized through `IStateRepository` +- Dispose the service when done to release git handles + +```csharp +// Always dispose +await using var service = await GitMessageImproverService.CreateAsync(options); +// or +using var service = ...; +// or explicitly +service.Dispose(); +``` diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs new file mode 100755 index 0000000..894a25b --- /dev/null +++ b/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; +using MarketAlly.GitCommitEditor.Options; +using MarketAlly.GitCommitEditor.Rewriters; +using MarketAlly.GitCommitEditor.Services; + +namespace MarketAlly.GitCommitEditor.Extensions; + +public static class ServiceCollectionExtensions +{ + /// + /// Adds GitMessageImprover services to the service collection. + /// + public static IServiceCollection AddGitMessageImprover( + this IServiceCollection services, + Action configureOptions) + { + var options = new GitImproverOptions(); + configureOptions(options); + options.ValidateAndThrow(); + + services.AddSingleton(options); + services.AddSingleton(options.Rules); + services.AddSingleton(options.Ai); + + services.AddSingleton(sp => + new FileStateRepository(options.StateFilePath)); + + // Cost tracking service (singleton to accumulate costs across session) + // Note: App should register ICostPersistenceProvider before calling AddGitMessageImprover + // for persistence support, or costs will only be tracked for the current session + services.AddSingleton(sp => + { + var persistence = sp.GetService(); + return new CostTrackingService(persistence); + }); + + services.AddSingleton(); + services.AddSingleton(); + + // Use DynamicCommitRewriter which switches between AI and Mock at runtime + // based on whether API key is configured + services.AddSingleton(sp => new DynamicCommitRewriter( + sp.GetRequiredService(), + sp.GetRequiredService())); + + // History health analysis services + services.AddSingleton(); + services.AddSingleton(sp => + new CommitAnalyzer(sp.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + + // Cleanup executor + services.AddSingleton(sp => new CleanupExecutor( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + // Git diagnostic service (AI-powered issue diagnosis) + services.AddSingleton(sp => + new GitDiagnosticService( + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(); + + return services; + } +} diff --git a/MarketAlly.GitCommitEditor.csproj b/MarketAlly.GitCommitEditor.csproj new file mode 100755 index 0000000..0d2e27a --- /dev/null +++ b/MarketAlly.GitCommitEditor.csproj @@ -0,0 +1,94 @@ + + + + net9.0 + enable + enable + C:\Users\logik\Dropbox\Nugets + + + true + MarketAlly.GitCommitEditor + MarketAlly Git Commit Editor - AI-Powered Commit Message Improvement + 1.0.0 + David H Friedel Jr + MarketAlly + A production-ready .NET 9 library for analyzing, improving, and rewriting git commit messages. Features include quality scoring against configurable rules, AI-powered suggestions via Claude/OpenAI/Gemini/Mistral/Qwen, commit history rewriting, push operations, and full DI support. + Copyright © MarketAlly 2025 + icon.png + git;commit;message;ai;claude;openai;gemini;libgit2;conventional-commits;code-quality;devtools;dotnet9;net9 + https://github.com/MarketAlly/GitCommitEditor + https://github.com/MarketAlly/GitCommitEditor + git + MIT + README.md + +Version 1.0.0 - Initial Release: +- Commit message quality analysis with configurable rules +- AI-powered suggestion generation (Claude, OpenAI, Gemini, Mistral, Qwen) +- Repository discovery and management +- Commit rewriting (amend latest or reword older commits) +- Push operations with force push support +- LRU repository cache with TTL +- State persistence between sessions +- Full dependency injection support +- Interface segregation (IRepositoryManager, ICommitAnalysisService, ISuggestionService, ICommitRewriteService, IGitPushService) + + false + + + Analyze, improve, and rewrite git commit messages using configurable rules and AI-powered suggestions from Claude, OpenAI, Gemini, Mistral, or Qwen. + en-US + + + true + true + snupkg + + + + + + + + + + + + + + + true + \ + PreserveNewest + true + + + true + \ + + + true + docs\ + + + + + + + ResXFileCodeGenerator + LibStrings.Designer.cs + + + LibStrings.resx + + + + + True + True + LibStrings.resx + + + + diff --git a/Models/BatchResult.cs b/Models/BatchResult.cs new file mode 100644 index 0000000..50fce45 --- /dev/null +++ b/Models/BatchResult.cs @@ -0,0 +1,10 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public sealed class BatchResult +{ + public int TotalProcessed { get; init; } + public int Successful { get; init; } + public int Failed { get; init; } + public int Skipped { get; init; } + public IReadOnlyList Operations { get; init; } = []; +} diff --git a/Models/BatchSuggestionResult.cs b/Models/BatchSuggestionResult.cs new file mode 100644 index 0000000..d07fb62 --- /dev/null +++ b/Models/BatchSuggestionResult.cs @@ -0,0 +1,53 @@ +namespace MarketAlly.GitCommitEditor.Models; + +/// +/// Result of batch AI suggestion generation. +/// +public class BatchSuggestionResult +{ + /// + /// All analyses that were processed. + /// + public IReadOnlyList Analyses { get; init; } = Array.Empty(); + + /// + /// Number of commits that got successful AI suggestions. + /// + public int SuccessCount { get; init; } + + /// + /// Number of commits where AI failed to generate a different suggestion. + /// + public int FailedCount { get; init; } + + /// + /// Details of each failure for logging/display. + /// + public IReadOnlyList Failures { get; init; } = Array.Empty(); +} + +/// +/// Details of a single suggestion failure. +/// +public class SuggestionFailure +{ + /// + /// The commit hash (short form). + /// + public string CommitHash { get; init; } = string.Empty; + + /// + /// The original message that couldn't be improved. + /// + public string OriginalMessage { get; init; } = string.Empty; + + /// + /// Why the suggestion failed. + /// + public string Reason { get; init; } = string.Empty; + + /// + /// Raw AI response for debugging (if available). + /// + public string? RawResponse { get; init; } +} diff --git a/Models/BranchInfo.cs b/Models/BranchInfo.cs new file mode 100644 index 0000000..096c2a8 --- /dev/null +++ b/Models/BranchInfo.cs @@ -0,0 +1,39 @@ +using System.Collections.ObjectModel; + +namespace MarketAlly.GitCommitEditor.Models; + +/// +/// Represents a branch in a Git repository for display in TreeView. +/// +public class BranchInfo +{ + public string Name { get; init; } = string.Empty; + public string FullName { get; init; } = string.Empty; + public bool IsRemote { get; init; } + public bool IsCurrentHead { get; init; } + public string? LastCommitSha { get; init; } + public DateTimeOffset? LastCommitDate { get; init; } + public string? RemoteName { get; init; } +} + +/// +/// Hierarchical node for TreeView display - can represent a repo or a branch category. +/// +public class BranchTreeNode +{ + public string Name { get; set; } = string.Empty; + public string? Icon { get; set; } + public bool IsExpanded { get; set; } = true; + public BranchInfo? Branch { get; set; } + public ObservableCollection Children { get; set; } = new(); + + /// + /// Display name including icon for current branch indicator + /// + public string DisplayName => Branch?.IsCurrentHead == true ? $"* {Name}" : Name; + + /// + /// Whether this node represents an actual branch (leaf node) + /// + public bool IsBranch => Branch != null; +} diff --git a/Models/CommitAnalysis.cs b/Models/CommitAnalysis.cs new file mode 100644 index 0000000..170180d --- /dev/null +++ b/Models/CommitAnalysis.cs @@ -0,0 +1,23 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public sealed class CommitAnalysis +{ + public required string RepoId { get; init; } + public required string RepoName { get; init; } + public required string RepoPath { get; init; } + public required string CommitHash { get; init; } + public string ShortHash => CommitHash.Length >= 7 ? CommitHash[..7] : CommitHash; + public required string OriginalMessage { get; init; } + public string? SuggestedMessage { get; set; } + public required DateTimeOffset CommitDate { get; init; } + public required string Author { get; init; } + public required string AuthorEmail { get; init; } + public required MessageQualityScore Quality { get; init; } + public IReadOnlyList FilesChanged { get; init; } = []; + public string? DiffSummary { get; init; } + public IReadOnlyDictionary FileDiffs { get; init; } = new Dictionary(); + public int LinesAdded { get; init; } + public int LinesDeleted { get; init; } + public AnalysisStatus Status { get; set; } = AnalysisStatus.Pending; + public bool IsLatestCommit { get; init; } +} diff --git a/Models/CommitContext.cs b/Models/CommitContext.cs new file mode 100644 index 0000000..d6d5d6b --- /dev/null +++ b/Models/CommitContext.cs @@ -0,0 +1,17 @@ +namespace MarketAlly.GitCommitEditor.Models; + +/// +/// Context about the commit's actual changes for smarter analysis. +/// +public sealed class CommitContext +{ + public int FilesChanged { get; init; } + public int LinesAdded { get; init; } + public int LinesDeleted { get; init; } + public IReadOnlyList FileNames { get; init; } = []; + + public static CommitContext Empty => new(); + + public int TotalLinesChanged => LinesAdded + LinesDeleted; + public bool HasSignificantChanges => FilesChanged > 0 || TotalLinesChanged > 0; +} diff --git a/Models/Enums.cs b/Models/Enums.cs new file mode 100644 index 0000000..efd4671 --- /dev/null +++ b/Models/Enums.cs @@ -0,0 +1,26 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public enum IssueSeverity +{ + Info, + Warning, + Error +} + +public enum AnalysisStatus +{ + Pending, + Analyzed, + Approved, + Applied, + Skipped, + Failed +} + +public enum OperationStatus +{ + Pending, + Applied, + Failed, + RolledBack +} diff --git a/Models/HistoryHealth/HealthEnums.cs b/Models/HistoryHealth/HealthEnums.cs new file mode 100644 index 0000000..90ba3c4 --- /dev/null +++ b/Models/HistoryHealth/HealthEnums.cs @@ -0,0 +1,207 @@ +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Models.HistoryHealth; + +/// +/// Overall health grade for a repository. +/// +public enum HealthGrade +{ + /// 90-100: Best practices followed. + Excellent, + /// 70-89: Minor issues, generally healthy. + Good, + /// 50-69: Noticeable issues, needs attention. + Fair, + /// 30-49: Significant problems, cleanup recommended. + Poor, + /// 0-29: Severe issues, immediate action required. + Critical +} + +/// +/// Type of duplicate commit detected. +/// +public enum DuplicateType +{ + /// Same tree SHA (identical content). + ExactTree, + /// Same message, different trees. + ExactMessage, + /// Similar messages (fuzzy match). + FuzzyMessage, + /// Same patch ID (cherry-picked). + CherryPick, + /// Same commit rebased. + RebasedCommit +} + +/// +/// Branch topology classification. +/// +public enum BranchTopologyType +{ + /// Minimal branching, mostly linear. + Linear, + /// Standard develop + release branches. + GitFlow, + /// Healthy feature branches. + Balanced, + /// Excessive cross-merges. + Tangled, + /// Critical complexity. + Spaghetti +} + +/// +/// Trend direction for metrics over time. +/// +public enum TrendDirection +{ + Improving, + Stable, + Declining +} + +/// +/// Severity of a health issue. +/// +public enum HealthIssueSeverity +{ + Info, + Warning, + Error, + Critical +} + +/// +/// Level of automation for cleanup operations. +/// +public enum CleanupAutomationLevel +{ + /// Can run with one click. + FullyAutomated, + /// Requires user review/approval. + SemiAutomated, + /// Requires manual git commands. + Manual +} + +/// +/// Type of cleanup operation. +/// +public enum CleanupType +{ + SquashDuplicates, + RewordMessages, + SquashMerges, + RebaseLinearize, + ArchiveBranches, + FixAuthorship, + ConsolidateMerges +} + +/// +/// Risk level for a cleanup operation. +/// +public enum RiskLevel +{ + /// Safe, no history changes. + None, + /// Message-only changes. + Low, + /// Squashing, requires force push. + Medium, + /// Structural changes, potential conflicts. + High, + /// Major rewrite, backup required. + VeryHigh +} + +/// +/// Status of a cleanup operation. +/// +public enum CleanupOperationStatus +{ + Suggested, + Approved, + InProgress, + Completed, + Failed, + Skipped +} + +/// +/// Estimated effort for a task. +/// +public enum EstimatedEffort +{ + /// Less than 1 hour. + Minimal, + /// 1-4 hours. + Low, + /// 1-2 days. + Medium, + /// More than 2 days. + High, + /// More than 1 week. + VeryHigh +} + +/// +/// Analysis depth for history scanning. +/// +public enum AnalysisDepth +{ + /// Sample 200 commits, basic metrics only. + Quick, + /// 1000 commits, all metrics. + Standard, + /// 5000 commits, comprehensive. + Deep, + /// All commits (slow for large repos). + Full +} + +/// +/// Report output format. +/// +public enum ReportFormat +{ + Json, + Markdown, + Html, + Console +} + +public static class HealthGradeExtensions +{ + public static string GetDescription(this HealthGrade grade) => grade switch + { + HealthGrade.Excellent => "Repository follows git best practices. Minimal cleanup needed.", + HealthGrade.Good => "Repository is generally healthy with minor issues.", + HealthGrade.Fair => Str.HealthStatus_NeedsAttention, + HealthGrade.Poor => "Repository has significant problems. Cleanup recommended.", + HealthGrade.Critical => Str.HealthStatus_Critical, + _ => "Unknown" + }; + + public static string GetIcon(this HealthGrade grade) => grade switch + { + HealthGrade.Excellent => "✅", + HealthGrade.Good => "👍", + HealthGrade.Fair => "⚠️", + HealthGrade.Poor => "❌", + HealthGrade.Critical => "🚨", + _ => "❓" + }; + + public static HealthGrade FromScore(int score) => score switch + { + >= 90 => HealthGrade.Excellent, + >= 70 => HealthGrade.Good, + >= 50 => HealthGrade.Fair, + >= 30 => HealthGrade.Poor, + _ => HealthGrade.Critical + }; +} diff --git a/Models/HistoryHealth/HealthMetrics.cs b/Models/HistoryHealth/HealthMetrics.cs new file mode 100644 index 0000000..ad0d536 --- /dev/null +++ b/Models/HistoryHealth/HealthMetrics.cs @@ -0,0 +1,144 @@ +namespace MarketAlly.GitCommitEditor.Models.HistoryHealth; + +/// +/// Represents a group of duplicate commits. +/// +public sealed class DuplicateCommitGroup +{ + public required string CanonicalMessage { get; init; } + public required IReadOnlyList CommitHashes { get; init; } + public required DuplicateType Type { get; init; } + public int InstanceCount => CommitHashes.Count; +} + +/// +/// Metrics for duplicate commit detection. +/// +public sealed class DuplicateCommitMetrics +{ + public int TotalCommitsAnalyzed { get; init; } + public int TotalDuplicateGroups { get; init; } + public int TotalDuplicateInstances { get; init; } + public int ExactDuplicates { get; init; } + public int CherryPicks { get; init; } + public int FuzzyMatches { get; init; } + public IReadOnlyList DuplicateGroups { get; init; } = []; + + public double DuplicateRatio => TotalCommitsAnalyzed > 0 + ? (double)TotalDuplicateInstances / TotalCommitsAnalyzed * 100 + : 0; +} + +/// +/// Metrics for merge commit analysis. +/// +public sealed class MergeCommitMetrics +{ + public int TotalCommits { get; init; } + public int TotalMerges { get; init; } + public int TrivialMerges { get; init; } + public int ConflictMerges { get; init; } + public int MergeFixCommits { get; init; } + public int NullMerges { get; init; } + public double AverageMergeComplexity { get; init; } + public IReadOnlyList MessyMergePatterns { get; init; } = []; + public IReadOnlyList MergeFixCommitHashes { get; init; } = []; + + public int MergeRatio => TotalCommits > 0 + ? (int)Math.Round((double)TotalMerges / TotalCommits * 100) + : 0; +} + +/// +/// Metrics for branch complexity analysis. +/// +public sealed class BranchComplexityMetrics +{ + public int TotalBranches { get; init; } + public int ActiveBranches { get; init; } + public int StaleBranches { get; init; } + public int CrossMerges { get; init; } + public double AverageBranchAge { get; init; } + public double AverageBranchLength { get; init; } + public int LongLivedBranches { get; init; } + public BranchTopologyType Topology { get; init; } + public IReadOnlyList StaleBranchNames { get; init; } = []; +} + +/// +/// A cluster of commits with similar quality scores. +/// +public sealed class QualityCluster +{ + public DateTimeOffset StartDate { get; init; } + public DateTimeOffset EndDate { get; init; } + public int CommitCount { get; init; } + public double AverageScore { get; init; } + public string? PossibleCause { get; init; } +} + +/// +/// Distribution of commit message quality scores. +/// +public sealed class MessageQualityDistribution +{ + public int TotalCommits { get; init; } + public int Excellent { get; init; } + public int Good { get; init; } + public int Fair { get; init; } + public int Poor { get; init; } + public double AverageScore { get; init; } + public double MedianScore { get; init; } + public double StandardDeviation { get; init; } + public TrendDirection Trend { get; init; } + public IReadOnlyList Clusters { get; init; } = []; + public IReadOnlyList PoorCommitHashes { get; init; } = []; +} + +/// +/// Statistics for a single author. +/// +public sealed class AuthorStats +{ + public required string Name { get; init; } + public required string Email { get; init; } + public int CommitCount { get; init; } + public double AverageMessageQuality { get; init; } + public int MergeCommitCount { get; init; } +} + +/// +/// Metrics for authorship analysis. +/// +public sealed class AuthorshipMetrics +{ + public int TotalAuthors { get; init; } + public int TotalCommits { get; init; } + public int MissingEmailCount { get; init; } + public int InvalidEmailCount { get; init; } + public int BotCommits { get; init; } + public IReadOnlyDictionary AuthorBreakdown { get; init; } + = new Dictionary(); +} + +/// +/// Component scores breakdown. +/// +public sealed class ComponentScores +{ + public int DuplicateScore { get; init; } + public int MergeScore { get; init; } + public int BranchScore { get; init; } + public int MessageScore { get; init; } + public int AuthorshipScore { get; init; } +} + +/// +/// Overall health score with breakdown. +/// +public sealed class HealthScore +{ + public int OverallScore { get; init; } + public HealthGrade Grade { get; init; } + public required ComponentScores ComponentScores { get; init; } +} diff --git a/Models/HistoryHealth/HealthReport.cs b/Models/HistoryHealth/HealthReport.cs new file mode 100644 index 0000000..e3339c1 --- /dev/null +++ b/Models/HistoryHealth/HealthReport.cs @@ -0,0 +1,148 @@ +namespace MarketAlly.GitCommitEditor.Models.HistoryHealth; + +/// +/// A health issue detected in the repository. +/// +public sealed class HealthIssue +{ + public required string Code { get; init; } + public required string Category { get; init; } + public required HealthIssueSeverity Severity { get; init; } + public required string Title { get; init; } + public required string Description { get; init; } + public int ImpactScore { get; init; } + public IReadOnlyList AffectedCommits { get; init; } = []; +} + +/// +/// A recommendation for improving repository health. +/// +public sealed class HealthRecommendation +{ + public required string Category { get; init; } + public required string Title { get; init; } + public required string Description { get; init; } + public required string Action { get; init; } + public string? Rationale { get; init; } + public int PriorityScore { get; init; } + public EstimatedEffort Effort { get; init; } + public int ExpectedScoreImprovement { get; init; } +} + +/// +/// A cleanup operation that can be performed. +/// +public sealed class CleanupOperation +{ + public required string Id { get; init; } + public required string Title { get; init; } + public required string Description { get; init; } + public required CleanupType Type { get; init; } + public required CleanupAutomationLevel AutomationLevel { get; init; } + public EstimatedEffort Effort { get; init; } + public RiskLevel Risk { get; init; } + public int ExpectedScoreImprovement { get; init; } + public IReadOnlyList AffectedCommits { get; init; } = []; + public string? GitCommand { get; init; } + public CleanupOperationStatus Status { get; set; } = CleanupOperationStatus.Suggested; +} + +/// +/// Cleanup suggestions organized by automation level. +/// +public sealed class CleanupSuggestions +{ + public IReadOnlyList AutomatedOperations { get; init; } = []; + public IReadOnlyList SemiAutomatedOperations { get; init; } = []; + public IReadOnlyList ManualOperations { get; init; } = []; + + public int TotalOperations => + AutomatedOperations.Count + SemiAutomatedOperations.Count + ManualOperations.Count; + + public int TotalExpectedImprovement => + AutomatedOperations.Sum(o => o.ExpectedScoreImprovement) + + SemiAutomatedOperations.Sum(o => o.ExpectedScoreImprovement) + + ManualOperations.Sum(o => o.ExpectedScoreImprovement); +} + +/// +/// Complete history health analysis results. +/// +public sealed class HistoryHealthAnalysis +{ + public required string RepoPath { get; init; } + public required string RepoName { get; init; } + public required string CurrentBranch { get; init; } + public DateTimeOffset AnalyzedAt { get; init; } = DateTimeOffset.UtcNow; + public int CommitsAnalyzed { get; init; } + public DateTimeOffset? OldestCommitDate { get; init; } + public DateTimeOffset? NewestCommitDate { get; init; } + + // Metrics + public required DuplicateCommitMetrics Duplicates { get; init; } + public required MergeCommitMetrics MergeMetrics { get; init; } + public required BranchComplexityMetrics BranchMetrics { get; init; } + public required MessageQualityDistribution MessageDistribution { get; init; } + public required AuthorshipMetrics AuthorshipMetrics { get; init; } +} + +/// +/// Complete health report with scoring, issues, and recommendations. +/// +public sealed class HistoryHealthReport +{ + public required string RepoId { get; init; } + public required string RepoName { get; init; } + public required string RepoPath { get; init; } + public required string CurrentBranch { get; init; } + public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow; + public int CommitsAnalyzed { get; init; } + + // Summary + public required HealthScore Score { get; init; } + + // Detailed metrics + public required DuplicateCommitMetrics DuplicateMetrics { get; init; } + public required MergeCommitMetrics MergeMetrics { get; init; } + public required BranchComplexityMetrics BranchMetrics { get; init; } + public required MessageQualityDistribution MessageDistribution { get; init; } + public required AuthorshipMetrics AuthorshipMetrics { get; init; } + + // Issues and recommendations + public IReadOnlyList Issues { get; init; } = []; + public IReadOnlyList Recommendations { get; init; } = []; + + // Cleanup opportunities + public CleanupSuggestions? CleanupSuggestions { get; init; } + + // Convenience properties + public int CriticalIssueCount => Issues.Count(i => i.Severity == HealthIssueSeverity.Critical); + public int ErrorCount => Issues.Count(i => i.Severity == HealthIssueSeverity.Error); + public int WarningCount => Issues.Count(i => i.Severity == HealthIssueSeverity.Warning); +} + +/// +/// Result of a cleanup operation. +/// +public sealed class CleanupResult +{ + public bool Success { get; init; } + public int CommitsModified { get; init; } + public int CommitsRemoved { get; init; } + public string? BackupBranch { get; init; } + public string? ErrorMessage { get; init; } + public HistoryHealthReport? UpdatedReport { get; init; } +} + +/// +/// Preview of what a cleanup operation will do. +/// +public sealed class CleanupPreview +{ + public int CommitsAffected { get; init; } + public int RefsAffected { get; init; } + public IReadOnlyList CommitsToModify { get; init; } = []; + public IReadOnlyList CommitsToRemove { get; init; } = []; + public int ExpectedScoreImprovement { get; init; } + public string Summary { get; init; } = string.Empty; +} diff --git a/Models/HistoryHealth/HistoryAnalysisOptions.cs b/Models/HistoryHealth/HistoryAnalysisOptions.cs new file mode 100644 index 0000000..9677382 --- /dev/null +++ b/Models/HistoryHealth/HistoryAnalysisOptions.cs @@ -0,0 +1,106 @@ +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Models.HistoryHealth; + +/// +/// Options for history health analysis. +/// +public sealed class HistoryAnalysisOptions +{ + /// + /// Analysis depth - affects number of commits analyzed. + /// + public AnalysisDepth Depth { get; set; } = AnalysisDepth.Standard; + + /// + /// Maximum commits to analyze. Overrides Depth if set. + /// + public int? MaxCommitsToAnalyze { get; set; } + + /// + /// Only analyze commits since this date. + /// + public DateTimeOffset? AnalyzeSince { get; set; } + + /// + /// Include duplicate commit detection. + /// + public bool IncludeDuplicateDetection { get; set; } = true; + + /// + /// Include branch complexity analysis. + /// + public bool IncludeBranchAnalysis { get; set; } = true; + + /// + /// Include message quality distribution. + /// + public bool IncludeMessageDistribution { get; set; } = true; + + /// + /// Branches to exclude from analysis. + /// + public string[] ExcludeBranches { get; set; } = []; + + /// + /// Generate cleanup suggestions. + /// + public bool GenerateCleanupSuggestions { get; set; } = true; + + /// + /// Gets the effective max commits based on depth. + /// + public int EffectiveMaxCommits => MaxCommitsToAnalyze ?? Depth switch + { + AnalysisDepth.Quick => 200, + AnalysisDepth.Standard => 1000, + AnalysisDepth.Deep => 5000, + AnalysisDepth.Full => int.MaxValue, + _ => 1000 + }; +} + +/// +/// Scoring weights for health calculation. +/// +public sealed class HealthScoringWeights +{ + public double DuplicateWeight { get; set; } = 0.20; + public double MergeWeight { get; set; } = 0.25; + public double BranchWeight { get; set; } = 0.20; + public double MessageWeight { get; set; } = 0.25; + public double AuthorshipWeight { get; set; } = 0.10; + + public void Validate() + { + var sum = DuplicateWeight + MergeWeight + BranchWeight + MessageWeight + AuthorshipWeight; + if (Math.Abs(sum - 1.0) > 0.001) + throw new InvalidOperationException(Str.Validation_WeightsSum(sum)); + } + + public static HealthScoringWeights Default => new(); +} + +/// +/// Options for duplicate detection. +/// +public sealed class DuplicateDetectionOptions +{ + public bool DetectExactTreeDuplicates { get; set; } = true; + public bool DetectCherryPicks { get; set; } = true; + public bool DetectFuzzyMatches { get; set; } = true; + public int FuzzyMatchThreshold { get; set; } = 3; + public int TimeWindowMinutes { get; set; } = 5; +} + +/// +/// Progress information during analysis. +/// +public sealed class AnalysisProgress +{ + public string CurrentStage { get; init; } = string.Empty; + public int PercentComplete { get; init; } + public int CommitsProcessed { get; init; } + public int TotalCommits { get; init; } + public string? CurrentItem { get; init; } +} diff --git a/Models/ImproverState.cs b/Models/ImproverState.cs new file mode 100644 index 0000000..0d72e78 --- /dev/null +++ b/Models/ImproverState.cs @@ -0,0 +1,51 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public sealed class ImproverState +{ + private const int DefaultMaxHistorySize = 1000; + private const int DefaultMaxHistoryAgeDays = 90; + + public List Repos { get; set; } = []; + public List History { get; set; } = []; + public Dictionary LastAnalyzedCommits { get; set; } = []; + public DateTimeOffset LastUpdated { get; set; } = DateTimeOffset.UtcNow; + + /// + /// Prunes history to keep only recent entries within size and age limits. + /// + /// Maximum number of history entries to retain. + /// Maximum age in days for history entries. + /// Number of entries removed. + public int PruneHistory(int maxSize = DefaultMaxHistorySize, int maxAgeDays = DefaultMaxHistoryAgeDays) + { + var initialCount = History.Count; + var cutoffDate = DateTimeOffset.UtcNow.AddDays(-maxAgeDays); + + // Remove entries older than max age + History.RemoveAll(h => h.CreatedAt < cutoffDate); + + // If still over size limit, keep only the most recent entries + if (History.Count > maxSize) + { + var sorted = History.OrderByDescending(h => h.CreatedAt).ToList(); + History.Clear(); + History.AddRange(sorted.Take(maxSize)); + } + + return initialCount - History.Count; + } + + /// + /// Removes history entries for repositories that are no longer registered. + /// + /// Number of orphaned entries removed. + public int RemoveOrphanedHistory() + { + var repoIds = Repos.Select(r => r.Id).ToHashSet(); + var initialCount = History.Count; + + History.RemoveAll(h => !repoIds.Contains(h.RepoId)); + + return initialCount - History.Count; + } +} diff --git a/Models/ManagedRepo.cs b/Models/ManagedRepo.cs new file mode 100644 index 0000000..cc87b1c --- /dev/null +++ b/Models/ManagedRepo.cs @@ -0,0 +1,17 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public sealed class ManagedRepo +{ + public string Id { get; init; } = Guid.NewGuid().ToString("N")[..8]; + public string Name { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public string? RemoteUrl { get; init; } + public string? CurrentBranch { get; init; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastScannedAt { get; set; } + public DateTimeOffset? LastAnalyzedAt { get; set; } + public int TotalCommits { get; set; } + public int CommitsNeedingImprovement { get; set; } + + public override string ToString() => Name; +} diff --git a/Models/MessageQualityScore.cs b/Models/MessageQualityScore.cs new file mode 100644 index 0000000..0a7b535 --- /dev/null +++ b/Models/MessageQualityScore.cs @@ -0,0 +1,8 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public sealed class MessageQualityScore +{ + public int OverallScore { get; init; } + public IReadOnlyList Issues { get; init; } = []; + public bool NeedsImprovement => OverallScore < 70 || Issues.Any(); +} diff --git a/Models/QualityIssue.cs b/Models/QualityIssue.cs new file mode 100644 index 0000000..5b4abe5 --- /dev/null +++ b/Models/QualityIssue.cs @@ -0,0 +1,8 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public sealed class QualityIssue +{ + public required IssueSeverity Severity { get; init; } + public required string Code { get; init; } + public required string Message { get; init; } +} diff --git a/Models/Results.cs b/Models/Results.cs new file mode 100644 index 0000000..08123e8 --- /dev/null +++ b/Models/Results.cs @@ -0,0 +1,57 @@ +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Models; + +/// +/// Result of a push operation. +/// +public sealed record GitPushResult(bool Success, string Message) +{ + public static GitPushResult Ok(string? message = null) => new(true, message ?? Str.Service_PushSuccess); + public static GitPushResult Fail(string message) => new(false, message); +} + +/// +/// Tracking information for a branch. +/// +public sealed record TrackingInfo( + string? UpstreamBranch, + int? AheadBy, + int? BehindBy) +{ + public static TrackingInfo None => new(null, null, null); + public bool HasUpstream => UpstreamBranch != null; + public bool IsInSync => AheadBy == 0 && BehindBy == 0; +} + +/// +/// Result of an AI suggestion operation. +/// +public sealed record SuggestionResult( + CommitAnalysis? Analysis, + string? Suggestion, + string? ErrorMessage = null, + string? RawResponse = null, + bool ReturnedOriginal = false, + int InputTokens = 0, + int OutputTokens = 0, + decimal EstimatedCost = 0) +{ + public bool Success => ErrorMessage == null && Suggestion != null; + public int TotalTokens => InputTokens + OutputTokens; + + public static SuggestionResult Ok(CommitAnalysis analysis, string suggestion, int inputTokens = 0, int outputTokens = 0, decimal cost = 0) + => new(analysis, suggestion, InputTokens: inputTokens, OutputTokens: outputTokens, EstimatedCost: cost); + + public static SuggestionResult Succeeded(string suggestion, int inputTokens = 0, int outputTokens = 0, decimal cost = 0) + => new(null, suggestion, InputTokens: inputTokens, OutputTokens: outputTokens, EstimatedCost: cost); + + public static SuggestionResult Fail(CommitAnalysis analysis, string error) + => new(analysis, null, error); + + public static SuggestionResult Failed(string error, string? rawResponse = null) + => new(null, null, error, rawResponse); + + public static SuggestionResult FailedWithOriginal(string originalMessage, string? rawResponse = null, int inputTokens = 0, int outputTokens = 0, decimal cost = 0) + => new(null, originalMessage, Str.Service_AiFallback, rawResponse, ReturnedOriginal: true, InputTokens: inputTokens, OutputTokens: outputTokens, EstimatedCost: cost); +} diff --git a/Models/RewriteOperation.cs b/Models/RewriteOperation.cs new file mode 100644 index 0000000..aadeee7 --- /dev/null +++ b/Models/RewriteOperation.cs @@ -0,0 +1,17 @@ +namespace MarketAlly.GitCommitEditor.Models; + +public sealed class RewriteOperation +{ + public string Id { get; init; } = Guid.NewGuid().ToString("N")[..12]; + public required string RepoId { get; init; } + public required string RepoPath { get; init; } + public required string CommitHash { get; init; } + public required string OriginalMessage { get; init; } + public required string NewMessage { get; init; } + public string? NewCommitHash { get; set; } + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset? AppliedAt { get; set; } + public OperationStatus Status { get; set; } = OperationStatus.Pending; + public string? ErrorMessage { get; set; } + public bool IsLatestCommit { get; init; } +} diff --git a/Models/RewriteSafetyInfo.cs b/Models/RewriteSafetyInfo.cs new file mode 100644 index 0000000..8aceb66 --- /dev/null +++ b/Models/RewriteSafetyInfo.cs @@ -0,0 +1,171 @@ +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Models; + +/// +/// Safety information for a batch rewrite operation. +/// +public sealed class RewriteSafetyInfo +{ + /// + /// Whether the repository has uncommitted changes. + /// + public bool HasUncommittedChanges { get; init; } + + /// + /// Whether any commits to be rewritten have been pushed to remote. + /// + public bool HasPushedCommits { get; init; } + + /// + /// Number of commits that have been pushed to remote. + /// + public int PushedCommitCount { get; init; } + + /// + /// Number of commits that are local only. + /// + public int LocalOnlyCommitCount { get; init; } + + /// + /// Total commits to be rewritten. + /// + public int TotalCommitCount { get; init; } + + /// + /// Whether the current branch tracks a remote branch. + /// + public bool HasRemoteTracking { get; init; } + + /// + /// The remote tracking branch name, if any. + /// + public string? RemoteTrackingBranch { get; init; } + + /// + /// Number of commits ahead of remote. + /// + public int? AheadOfRemote { get; init; } + + /// + /// Number of commits behind remote. + /// + public int? BehindRemote { get; init; } + + /// + /// Whether a backup branch was created. + /// + public bool BackupBranchCreated { get; init; } + + /// + /// Name of the backup branch, if created. + /// + public string? BackupBranchName { get; init; } + + /// + /// Whether the operation can proceed safely. + /// + public bool CanProceedSafely => !HasUncommittedChanges && !HasPushedCommits; + + /// + /// Whether the operation can proceed with warnings. + /// + public bool CanProceedWithWarnings => !HasUncommittedChanges; + + /// + /// Gets warning messages based on the safety info. + /// + public IReadOnlyList GetWarnings() + { + var warnings = new List(); + + if (HasUncommittedChanges) + { + warnings.Add(Str.Safety_UncommittedChanges); + } + + if (HasPushedCommits) + { + warnings.Add(Str.Safety_PushedCommits(PushedCommitCount)); + } + + if (BehindRemote > 0) + { + warnings.Add(Str.Safety_BehindRemote(BehindRemote.Value)); + } + + return warnings; + } + + /// + /// Gets a summary description of the operation. + /// + public string GetSummary() + { + var parts = new List(); + + if (LocalOnlyCommitCount > 0) + { + parts.Add($"{LocalOnlyCommitCount} local commit(s)"); + } + + if (PushedCommitCount > 0) + { + parts.Add($"{PushedCommitCount} pushed commit(s)"); + } + + return string.Join(" and ", parts) + " will be rewritten."; + } +} + +/// +/// Result of a batch rewrite execute operation. +/// +public sealed class BatchRewriteResult +{ + /// + /// Whether the overall operation succeeded. + /// + public bool Success { get; init; } + + /// + /// Number of commits successfully rewritten. + /// + public int SuccessCount { get; init; } + + /// + /// Number of commits that failed to rewrite. + /// + public int FailedCount { get; init; } + + /// + /// Number of commits skipped. + /// + public int SkippedCount { get; init; } + + /// + /// Error message if the operation failed. + /// + public string? ErrorMessage { get; init; } + + /// + /// Whether a force push is required. + /// + public bool RequiresForcePush { get; init; } + + /// + /// The backup branch name if one was created. + /// + public string? BackupBranchName { get; init; } + + /// + /// Individual operation results. + /// + public IReadOnlyList Operations { get; init; } = []; + + public static BatchRewriteResult Failure(string error) => new() + { + Success = false, + ErrorMessage = error + }; +} diff --git a/Options/AiOptions.cs b/Options/AiOptions.cs new file mode 100755 index 0000000..c2c2c9f --- /dev/null +++ b/Options/AiOptions.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using MarketAlly.AIPlugin; +using MarketAlly.AIPlugin.Conversation; + +namespace MarketAlly.GitCommitEditor.Options; + +/// +/// Model information for display in settings UI +/// +public sealed class ModelDisplayInfo +{ + public string ModelId { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string Tier { get; init; } = string.Empty; + public decimal InputCostPer1MTokens { get; init; } + public decimal OutputCostPer1MTokens { get; init; } + public string Speed { get; init; } = string.Empty; + + /// + /// Formatted display string for picker: "DisplayName (Tier) - $X.XX/1M" + /// + public string FormattedDisplay => $"{DisplayName} ({Tier}) - ${InputCostPer1MTokens:F2}/1M in"; + + public override string ToString() => FormattedDisplay; +} + +/// +/// AI configuration options. +/// For provider/model discovery, use IModelProviderService instead of static methods. +/// +public sealed class AiOptions +{ + [JsonIgnore] + public string ApiKey { get; set; } = string.Empty; + + /// + /// AI Provider: Claude, OpenAI, Gemini, Qwen + /// + public string Provider { get; set; } = "Claude"; + + /// + /// Model name specific to the provider + /// + public string Model { get; set; } = ModelConstants.Claude.Sonnet4; + + public bool IncludeDiffContext { get; set; } = true; + public int MaxDiffLines { get; set; } = 200; + public int MaxTokens { get; set; } = 500; + public int RateLimitDelayMs { get; set; } = 500; + + /// + /// Parses a provider string to AIProvider enum. + /// + public static AIProvider ParseProvider(string? provider) + { + return provider?.ToLowerInvariant() switch + { + "claude" or "anthropic" => AIProvider.Claude, + "openai" or "gpt" => AIProvider.OpenAI, + "gemini" or "google" => AIProvider.Gemini, + "qwen" or "alibaba" => AIProvider.Qwen, + _ => AIProvider.Claude + }; + } +} diff --git a/Options/CommitMessageRules.cs b/Options/CommitMessageRules.cs new file mode 100644 index 0000000..d69f77e --- /dev/null +++ b/Options/CommitMessageRules.cs @@ -0,0 +1,23 @@ +namespace MarketAlly.GitCommitEditor.Options; + +public sealed class CommitMessageRules +{ + public int MinSubjectLength { get; set; } = 10; + public int MaxSubjectLength { get; set; } = 72; + public int MinBodyLength { get; set; } = 0; + public bool RequireConventionalCommit { get; set; } = false; + public bool RequireIssueReference { get; set; } = false; + + public string[] BannedPhrases { get; set; } = + [ + "fix", "wip", "temp", "asdf", "test", "stuff", "things", + "changes", "update", "updates", "misc", "minor", "oops", + "commit", "save", "checkpoint", "progress", "done", "finished" + ]; + + public string[] ConventionalTypes { get; set; } = + [ + "feat", "fix", "docs", "style", "refactor", "perf", + "test", "build", "ci", "chore", "revert" + ]; +} diff --git a/Options/GitImproverOptions.cs b/Options/GitImproverOptions.cs new file mode 100644 index 0000000..e15afb4 --- /dev/null +++ b/Options/GitImproverOptions.cs @@ -0,0 +1,89 @@ +using System.ComponentModel.DataAnnotations; +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Options; + +public sealed class GitImproverOptions : IValidatableObject +{ + /// + /// Root directory to scan for git repositories + /// + public string WorkspaceRoot { get; set; } = string.Empty; + + /// + /// Path to persist state between sessions + /// + public string StateFilePath { get; set; } = "git-improver-state.json"; + + /// + /// Rules for analyzing commit message quality + /// + public CommitMessageRules Rules { get; set; } = new(); + + /// + /// AI configuration for generating suggestions + /// + public AiOptions Ai { get; set; } = new(); + + /// + /// Maximum number of commits to analyze per repo + /// + public int MaxCommitsPerRepo { get; set; } = 100; + + /// + /// Only analyze commits newer than this date + /// + public DateTimeOffset? AnalyzeSince { get; set; } + + /// + /// Skip commits by these authors (useful for excluding bots) + /// + public string[] ExcludedAuthors { get; set; } = []; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(WorkspaceRoot)) + { + yield return new ValidationResult( + Str.Validation_WorkspaceRequired, + [nameof(WorkspaceRoot)]); + } + else if (!Directory.Exists(WorkspaceRoot)) + { + yield return new ValidationResult( + Str.Validation_WorkspaceNotFound(WorkspaceRoot), + [nameof(WorkspaceRoot)]); + } + + if (MaxCommitsPerRepo <= 0) + { + yield return new ValidationResult( + Str.Validation_MaxCommitsPositive, + [nameof(MaxCommitsPerRepo)]); + } + + if (Rules == null) + { + yield return new ValidationResult( + Str.Validation_RulesNull, + [nameof(Rules)]); + } + + if (Ai == null) + { + yield return new ValidationResult( + Str.Validation_AiOptionsNull, + [nameof(Ai)]); + } + } + + public void ValidateAndThrow() + { + var results = Validate(new ValidationContext(this)).ToList(); + if (results.Count > 0) + { + var messages = string.Join("; ", results.Select(r => r.ErrorMessage)); + throw new ValidationException(Str.Validation_InvalidOptions(messages)); + } + } +} diff --git a/Plugins/GenerateCommitMessagePlugin.cs b/Plugins/GenerateCommitMessagePlugin.cs new file mode 100644 index 0000000..f0d96f8 --- /dev/null +++ b/Plugins/GenerateCommitMessagePlugin.cs @@ -0,0 +1,135 @@ +using MarketAlly.AIPlugin; + +namespace MarketAlly.GitCommitEditor.Plugins; + +/// +/// Valid conventional commit types +/// +public enum CommitType +{ + /// New feature + feat, + /// Bug fix + fix, + /// Documentation only + docs, + /// Formatting, whitespace (no code change) + style, + /// Code change that neither fixes a bug nor adds a feature + refactor, + /// Performance improvement + perf, + /// Adding or updating tests + test, + /// Build system or dependencies + build, + /// CI/CD configuration + ci, + /// Maintenance tasks, logging, tooling + chore +} + +/// +/// Result model for the commit message generation +/// +public class CommitMessageResult +{ + public CommitType Type { get; set; } = CommitType.chore; + public string? Scope { get; set; } + public string Description { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + + /// + /// Gets the formatted subject line: type(scope): description + /// + public string Subject => string.IsNullOrEmpty(Scope) + ? $"{Type}: {Description}" + : $"{Type}({Scope}): {Description}"; +} + +/// +/// AI Plugin for generating improved commit messages following conventional commit format +/// +[AIPlugin("GenerateCommitMessage", "Generates an improved git commit message based on the original message, changed files, and diff content. Returns a structured result with subject line and optional body.")] +public class GenerateCommitMessagePlugin : IAIPlugin +{ + [AIParameter("The original commit message to improve", required: true)] + public string OriginalMessage { get; set; } = string.Empty; + + [AIParameter("List of file paths that were changed in the commit", required: true)] + public List FilesChanged { get; set; } = new(); + + [AIParameter("Number of lines added in the commit", required: true)] + public int LinesAdded { get; set; } + + [AIParameter("Number of lines deleted in the commit", required: true)] + public int LinesDeleted { get; set; } + + [AIParameter("Summary of the diff/changes (truncated)", required: false)] + public string? DiffSummary { get; set; } + + [AIParameter("List of quality issues detected in the original message", required: false)] + public List? QualityIssues { get; set; } + + public IReadOnlyDictionary SupportedParameters => new Dictionary + { + ["originalMessage"] = typeof(string), + ["filesChanged"] = typeof(List), + ["linesAdded"] = typeof(int), + ["linesDeleted"] = typeof(int), + ["diffSummary"] = typeof(string), + ["qualityIssues"] = typeof(List) + }; + + public Task ExecuteAsync(IReadOnlyDictionary parameters) + { + var result = new CommitMessageResult + { + Type = CommitType.chore, + Description = OriginalMessage, + Body = string.Empty + }; + + return Task.FromResult(new AIPluginResult(result, "Commit message generated")); + } +} + +/// +/// AI Plugin that the AI calls to return the generated commit message +/// +[AIPlugin("ReturnCommitMessage", "Returns the generated commit message. Call this after analyzing the commit to provide the improved message in conventional commit format.")] +public class ReturnCommitMessagePlugin : IAIPlugin +{ + [AIParameter("The commit type", required: true)] + public CommitType Type { get; set; } = CommitType.chore; + + [AIParameter("Optional scope/area of the codebase affected (e.g., 'api', 'ui', 'auth'). Omit if not applicable.", required: false)] + public string? Scope { get; set; } + + [AIParameter("Short description of the change (max 60 chars, imperative mood, no period). Example: 'add user authentication'", required: true)] + public string Description { get; set; } = string.Empty; + + [AIParameter("Optional extended description explaining what changed and why. Leave empty if the subject is self-explanatory.", required: false)] + public string Body { get; set; } = string.Empty; + + public IReadOnlyDictionary SupportedParameters => new Dictionary + { + ["type"] = typeof(CommitType), + ["scope"] = typeof(string), + ["description"] = typeof(string), + ["body"] = typeof(string) + }; + + public Task ExecuteAsync(IReadOnlyDictionary parameters) + { + var result = new CommitMessageResult + { + Type = Type, + Scope = Scope, + Description = Description, + Body = Body ?? string.Empty + }; + + return Task.FromResult(new AIPluginResult(result, "Commit message returned")); + } +} diff --git a/Plugins/GitDiagnosticPlugin.cs b/Plugins/GitDiagnosticPlugin.cs new file mode 100755 index 0000000..ab251d7 --- /dev/null +++ b/Plugins/GitDiagnosticPlugin.cs @@ -0,0 +1,92 @@ +using MarketAlly.AIPlugin; + +namespace MarketAlly.GitCommitEditor.Plugins; + +/// +/// Risk level for git fix operations +/// +public enum RiskLevel +{ + /// Safe operation, no data loss risk + Low, + /// Some risk, review before executing + Medium, + /// Potential data loss, use with caution + High +} + +/// +/// Result model for git issue diagnosis +/// +public class GitDiagnosisResult +{ + /// + /// Short summary of the problem (1-2 sentences) + /// + public string Problem { get; set; } = string.Empty; + + /// + /// Explanation of why this happened + /// + public string Cause { get; set; } = string.Empty; + + /// + /// The recommended fix command(s) to run + /// + public string FixCommand { get; set; } = string.Empty; + + /// + /// Warning about potential data loss or side effects (if any) + /// + public string Warning { get; set; } = string.Empty; + + /// + /// Risk level of the fix operation + /// + public RiskLevel RiskLevel { get; set; } = RiskLevel.Low; +} + +/// +/// AI Plugin that the AI calls to return the git diagnosis +/// +[AIPlugin("ReturnGitDiagnosis", "Returns the diagnosis of a git issue with the problem summary, cause, fix command, and any warnings.")] +public class ReturnGitDiagnosisPlugin : IAIPlugin +{ + [AIParameter("Short summary of the problem (1-2 sentences)", required: true)] + public string Problem { get; set; } = string.Empty; + + [AIParameter("Explanation of why this happened", required: true)] + public string Cause { get; set; } = string.Empty; + + [AIParameter("The exact git command(s) to fix the issue. Use newlines to separate multiple commands.", required: true)] + public string FixCommand { get; set; } = string.Empty; + + [AIParameter("Warning about potential data loss or side effects. Leave empty if no warnings.", required: false)] + public string Warning { get; set; } = string.Empty; + + [AIParameter("Risk level of the fix operation", required: true)] + public RiskLevel RiskLevel { get; set; } = RiskLevel.Low; + + public IReadOnlyDictionary SupportedParameters => new Dictionary + { + ["problem"] = typeof(string), + ["cause"] = typeof(string), + ["fixCommand"] = typeof(string), + ["warning"] = typeof(string), + ["riskLevel"] = typeof(RiskLevel) + }; + + public Task ExecuteAsync(IReadOnlyDictionary parameters) + { + var result = new GitDiagnosisResult + { + Problem = Problem, + Cause = Cause, + FixCommand = FixCommand, + Warning = Warning ?? string.Empty, + RiskLevel = RiskLevel + }; + + return Task.FromResult(new AIPluginResult(result, "Git diagnosis returned")); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..552a139 --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# MarketAlly.GitCommitEditor + +A production-ready .NET 9 library for analyzing, improving, and rewriting git commit messages using configurable quality rules and AI-powered suggestions. + +## Features + +- **Commit Message Analysis** - Score commit messages against configurable quality rules (subject length, conventional commits, banned phrases, etc.) +- **AI-Powered Suggestions** - Generate improved commit messages using Claude, OpenAI, Gemini, or Qwen +- **Repository Management** - Discover, register, and manage multiple git repositories +- **Commit Rewriting** - Amend latest commits or reword older commits in history with automatic backup +- **Batch Operations** - Process multiple commits across multiple repositories efficiently +- **History Health Analysis** - Comprehensive repository health scoring with cleanup recommendations +- **Push Operations** - Check push status, get tracking info, and perform force pushes +- **State Persistence** - Save and restore analysis state between sessions +- **Dependency Injection** - First-class support for Microsoft.Extensions.DependencyInjection + +## Installation + +```bash +dotnet add package MarketAlly.GitCommitEditor +``` + +## Quick Start + +### With Dependency Injection (Recommended) + +```csharp +using MarketAlly.GitCommitEditor.Extensions; + +services.AddGitMessageImprover(options => +{ + options.WorkspaceRoot = @"C:\Projects"; + options.StateFilePath = "git-state.json"; + options.MaxCommitsPerRepo = 100; + options.AnalyzeSince = DateTimeOffset.Now.AddMonths(-3); + options.ExcludedAuthors = ["dependabot[bot]@users.noreply.github.com"]; + + // Configure commit message rules + options.Rules.MinSubjectLength = 10; + options.Rules.MaxSubjectLength = 72; + options.Rules.RequireConventionalCommit = true; + options.Rules.RequireIssueReference = false; + + // Configure AI provider + options.Ai.Provider = "Claude"; + options.Ai.Model = "claude-sonnet-4-20250514"; + options.Ai.ApiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")!; + options.Ai.IncludeDiffContext = true; +}); +``` + +### Without DI (Simple Usage) + +```csharp +using MarketAlly.GitCommitEditor.Options; +using MarketAlly.GitCommitEditor.Services; + +var options = new GitImproverOptions +{ + WorkspaceRoot = @"C:\Projects", + Ai = { ApiKey = "your-api-key", Provider = "Claude" } +}; + +await using var service = await GitMessageImproverService.CreateAsync(options); +``` + +## Core Workflows + +### 1. Scan and Analyze Repositories + +```csharp +IGitMessageImproverService improver = ...; + +// Load persisted state +await improver.LoadStateAsync(); + +// Discover git repos in workspace +var newRepos = await improver.ScanAndRegisterReposAsync(); +Console.WriteLine($"Found {newRepos.Count} new repositories"); + +// Analyze all repos for commit message issues +var analyses = await improver.AnalyzeAllReposAsync( + onlyNeedsImprovement: true, + progress: new Progress<(string Repo, int Count)>(p => + Console.WriteLine($"Analyzed {p.Count} commits in {p.Repo}"))); + +Console.WriteLine($"Found {analyses.Count} commits needing improvement"); +``` + +### 2. Generate AI Suggestions + +```csharp +// Generate suggestions for commits that need improvement +var commitsToImprove = analyses.Where(a => a.Quality.NeedsImprovement).ToList(); + +var result = await improver.GenerateSuggestionsAsync( + commitsToImprove, + progress: new Progress(count => + Console.WriteLine($"Generated {count}/{commitsToImprove.Count} suggestions"))); + +Console.WriteLine($"Success: {result.SuccessCount}, Failed: {result.FailedCount}"); + +// Or generate for a single commit +var suggestion = await improver.GenerateSuggestionAsync(analyses.First()); +if (suggestion.Success) + Console.WriteLine($"Suggested: {suggestion.Suggestion}"); +``` + +### 3. Safe Batch Rewrite (Recommended) + +```csharp +// Check safety before rewriting +var safetyInfo = improver.GetRewriteSafetyInfo(repoPath, commitsToRewrite); + +if (!safetyInfo.CanProceedSafely) +{ + foreach (var warning in safetyInfo.GetWarnings()) + Console.WriteLine($"Warning: {warning}"); +} + +// Execute batch rewrite with automatic backup +var result = await improver.ExecuteBatchRewriteAsync( + repoPath, + commitsToRewrite, + createBackup: true, + progress: new Progress<(int Current, int Total, string Hash)>(p => + Console.WriteLine($"Rewriting {p.Current}/{p.Total}: {p.Hash}"))); + +if (result.Success) +{ + Console.WriteLine($"Rewrote {result.SuccessCount} commits"); + if (result.BackupBranchName != null) + Console.WriteLine($"Backup branch: {result.BackupBranchName}"); + if (result.RequiresForcePush) + Console.WriteLine("Force push required to update remote"); +} +``` + +### 4. Preview and Apply Changes (Granular Control) + +```csharp +// Preview changes before applying +var operations = improver.PreviewChanges(commitsToImprove); + +foreach (var op in operations) +{ + Console.WriteLine($"{op.CommitHash[..7]}: {op.OriginalMessage}"); + Console.WriteLine($" -> {op.NewMessage}"); +} + +// Apply changes (dryRun: false to actually modify commits) +var result = await improver.ApplyChangesAsync( + operations, + dryRun: false, + progress: new Progress<(int Processed, int Total)>(p => + Console.WriteLine($"Applied {p.Processed}/{p.Total}"))); + +Console.WriteLine($"Success: {result.Successful}, Failed: {result.Failed}"); +``` + +### 5. Repository History Health Analysis + +```csharp +// Analyze repository health +var healthReport = await improver.AnalyzeHistoryHealthAsync( + repoPath, + new HistoryAnalysisOptions { Depth = AnalysisDepth.Standard }, + progress: new Progress(p => + Console.WriteLine($"Analyzing: {p.PercentComplete}%"))); + +Console.WriteLine($"Health Score: {healthReport.Score.OverallScore}/100 ({healthReport.Score.Grade})"); +Console.WriteLine($"Issues: {healthReport.CriticalIssueCount} critical, {healthReport.ErrorCount} errors"); + +// Export report +var markdown = await improver.ExportHealthReportAsync(healthReport, ReportFormat.Markdown); + +// Execute cleanup suggestions +if (healthReport.CleanupSuggestions?.AutomatedOperations.Any() == true) +{ + var cleanupResult = await improver.ExecuteAllCleanupsAsync( + repo, + healthReport.CleanupSuggestions, + new CleanupExecutionOptions { CreateBackup = true }); + + Console.WriteLine($"Cleaned up {cleanupResult.Successful} issues"); +} +``` + +### 6. Handle Pushed Commits + +```csharp +// Check if commit is already pushed +bool isPushed = improver.IsCommitPushed(repoPath, commitHash); + +if (isPushed) + Console.WriteLine("Warning: Commit has been pushed. Force push required after amending."); + +// Get tracking info +var tracking = improver.GetTrackingInfo(repoPath); +Console.WriteLine($"Tracking: {tracking.UpstreamBranch}, Ahead: {tracking.AheadBy}, Behind: {tracking.BehindBy}"); + +// After amending a pushed commit, force push +var pushResult = improver.ForcePush(repoPath); +if (pushResult.Success) + Console.WriteLine("Force push successful"); +else + Console.WriteLine($"Push failed: {pushResult.Message}"); +``` + +## Configuration + +### GitImproverOptions + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `WorkspaceRoot` | string | required | Root directory to scan for git repositories | +| `StateFilePath` | string | `"git-improver-state.json"` | Path to persist state between sessions | +| `MaxCommitsPerRepo` | int | `100` | Maximum commits to analyze per repository | +| `AnalyzeSince` | DateTimeOffset? | `null` | Only analyze commits after this date | +| `ExcludedAuthors` | string[] | `[]` | Author emails to exclude (e.g., bots) | +| `Rules` | CommitMessageRules | default | Commit message quality rules | +| `Ai` | AiOptions | default | AI provider configuration | + +### CommitMessageRules + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `MinSubjectLength` | int | `10` | Minimum subject line length | +| `MaxSubjectLength` | int | `72` | Maximum subject line length | +| `MinBodyLength` | int | `0` | Minimum body length (0 = optional) | +| `RequireConventionalCommit` | bool | `false` | Require conventional commit format | +| `RequireIssueReference` | bool | `false` | Require issue/ticket reference | +| `BannedPhrases` | string[] | see below | Phrases that trigger warnings | +| `ConventionalTypes` | string[] | see below | Valid conventional commit types | + +**Default Banned Phrases:** `fix`, `wip`, `temp`, `asdf`, `test`, `stuff`, `things`, `changes`, `update`, `misc`, `minor`, `oops`, `commit`, `save`, `checkpoint`, `progress`, `done`, `finished` + +**Default Conventional Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` + +### AiOptions + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `ApiKey` | string | `""` | API key for the selected provider | +| `Provider` | string | `"Claude"` | AI provider (Claude, OpenAI, Gemini, Qwen) | +| `Model` | string | `"claude-sonnet-4-20250514"` | Model name for the provider | +| `IncludeDiffContext` | bool | `true` | Include diff in AI prompt | +| `MaxDiffLines` | int | `200` | Maximum diff lines to include | +| `MaxTokens` | int | `500` | Maximum tokens in AI response | +| `RateLimitDelayMs` | int | `500` | Delay between AI requests | + +## Interfaces + +The library follows Interface Segregation Principle with focused interfaces: + +| Interface | Purpose | +|-----------|---------| +| `IRepositoryManager` | Repository discovery and registration | +| `ICommitAnalysisService` | Commit message analysis | +| `ISuggestionService` | AI suggestion generation | +| `ICommitRewriteService` | Commit message rewriting | +| `IGitPushService` | Push operations and tracking | +| `IHistoryHealthService` | Repository health analysis and cleanup | +| `IGitMessageImproverService` | Unified facade (composes all above) | + +## Supported AI Providers + +| Provider | Models | +|----------|--------| +| **Claude** | claude-sonnet-4-20250514, claude-opus-4-20250514, claude-3-5-haiku-20241022 | +| **OpenAI** | gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-3.5-turbo | +| **Gemini** | gemini-1.5-pro, gemini-1.5-flash, gemini-pro | +| **Qwen** | qwen-turbo, qwen-plus, qwen-max | + +## Thread Safety + +- `GitOperationsService` uses an LRU cache with TTL for repository handles +- State operations are async and support cancellation tokens +- The service implements `IDisposable` - ensure proper disposal + +## Dependencies + +- .NET 9.0 +- MarketAlly.LibGit2Sharp (git operations) +- MarketAlly.AIPlugin (AI provider abstraction) +- Microsoft.Extensions.DependencyInjection.Abstractions + +## License + +MIT License - Copyright 2025 MarketAlly diff --git a/Resources/Strings/LibStrings.Designer.cs b/Resources/Strings/LibStrings.Designer.cs new file mode 100755 index 0000000..db3be32 --- /dev/null +++ b/Resources/Strings/LibStrings.Designer.cs @@ -0,0 +1,1145 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace MarketAlly.GitCommitEditor.Resources.Strings { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class LibStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal LibStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MarketAlly.GitCommitEditor.Resources.Strings.LibStrings", typeof(LibStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Subject uses non-descriptive phrase: '{0}'. + /// + internal static string Analyzer_BannedPhrase { + get { + return ResourceManager.GetString("Analyzer_BannedPhrase", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add a blank line between subject and body. + /// + internal static string Analyzer_BlankLine { + get { + return ResourceManager.GetString("Analyzer_BlankLine", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Body is {0} chars, minimum is {1}. + /// + internal static string Analyzer_BodyTooShort { + get { + return ResourceManager.GetString("Analyzer_BodyTooShort", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Subject should start with a capital letter. + /// + internal static string Analyzer_CapitalLetter { + get { + return ResourceManager.GetString("Analyzer_CapitalLetter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use imperative mood: '{0}' → '{1}' (e.g., 'Add' not 'Added'). + /// + internal static string Analyzer_ImperativeMood { + get { + return ResourceManager.GetString("Analyzer_ImperativeMood", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Large change ({0} files, {1} lines) deserves a more descriptive message. + /// + internal static string Analyzer_LargeChange { + get { + return ResourceManager.GetString("Analyzer_LargeChange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Major change ({0} files) should include a body explaining why. + /// + internal static string Analyzer_MajorChange { + get { + return ResourceManager.GetString("Analyzer_MajorChange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Consider mentioning what area changed (files: {0}). + /// + internal static string Analyzer_MentionArea { + get { + return ResourceManager.GetString("Analyzer_MentionArea", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Commit message is empty. + /// + internal static string Analyzer_MessageEmpty { + get { + return ResourceManager.GetString("Analyzer_MessageEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No issue reference found (e.g., #123 or JIRA-123). + /// + internal static string Analyzer_NoIssueRef { + get { + return ResourceManager.GetString("Analyzer_NoIssueRef", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Subject should not end with a period. + /// + internal static string Analyzer_NoPeriod { + get { + return ResourceManager.GetString("Analyzer_NoPeriod", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Message doesn't follow conventional commit format (type: subject). + /// + internal static string Analyzer_NotConventional { + get { + return ResourceManager.GetString("Analyzer_NotConventional", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' doesn't describe what changed in {1} files. + /// + internal static string Analyzer_NotDescriptive { + get { + return ResourceManager.GetString("Analyzer_NotDescriptive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Subject is {0} chars, recommended max is {1}. + /// + internal static string Analyzer_SubjectTooLong { + get { + return ResourceManager.GetString("Analyzer_SubjectTooLong", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Subject is {0} chars, minimum is {1}. + /// + internal static string Analyzer_SubjectTooShort { + get { + return ResourceManager.GetString("Analyzer_SubjectTooShort", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Message is too vague for {0} changed files - describe WHAT changed. + /// + internal static string Analyzer_TooVague { + get { + return ResourceManager.GetString("Analyzer_TooVague", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown conventional commit type: {0}. + /// + internal static string Analyzer_UnknownType { + get { + return ResourceManager.GetString("Analyzer_UnknownType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to History is already linear - no merge commits found. + /// + internal static string Cleanup_AlreadyLinear { + get { + return ResourceManager.GetString("Cleanup_AlreadyLinear", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Analyzing branch structure.... + /// + internal static string Cleanup_AnalyzingStructure { + get { + return ResourceManager.GetString("Cleanup_AnalyzingStructure", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Archive complete. + /// + internal static string Cleanup_ArchiveComplete { + get { + return ResourceManager.GetString("Cleanup_ArchiveComplete", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to archive branches: {0}. + /// + internal static string Cleanup_ArchiveFailed { + get { + return ResourceManager.GetString("Cleanup_ArchiveFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Archiving stale branches.... + /// + internal static string Cleanup_ArchivingBranches { + get { + return ResourceManager.GetString("Cleanup_ArchivingBranches", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to consolidate merge fix commits: {0}. + /// + internal static string Cleanup_ConsolidateFailed { + get { + return ResourceManager.GetString("Cleanup_ConsolidateFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Consolidating merge fix commits.... + /// + internal static string Cleanup_ConsolidatingFixes { + get { + return ResourceManager.GetString("Cleanup_ConsolidatingFixes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will archive stale branches (delete if merged, tag otherwise).. + /// + internal static string Cleanup_DescArchive { + get { + return ResourceManager.GetString("Cleanup_DescArchive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will fix authorship on {0} commit(s).. + /// + internal static string Cleanup_DescAuthorship { + get { + return ResourceManager.GetString("Cleanup_DescAuthorship", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will consolidate {0} merge-fix commits.. + /// + internal static string Cleanup_DescConsolidate { + get { + return ResourceManager.GetString("Cleanup_DescConsolidate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will process {0} commit(s).. + /// + internal static string Cleanup_DescGeneric { + get { + return ResourceManager.GetString("Cleanup_DescGeneric", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will linearize history by removing merge commits and sorting by date.. + /// + internal static string Cleanup_DescLinearize { + get { + return ResourceManager.GetString("Cleanup_DescLinearize", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will reword {0} commit message(s) to improve quality.. + /// + internal static string Cleanup_DescReword { + get { + return ResourceManager.GetString("Cleanup_DescReword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will squash {0} duplicate commits into 1.. + /// + internal static string Cleanup_DescSquash { + get { + return ResourceManager.GetString("Cleanup_DescSquash", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will consolidate {0} trivial merges.. + /// + internal static string Cleanup_DescTrivialMerges { + get { + return ResourceManager.GetString("Cleanup_DescTrivialMerges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to drop duplicate commits: {0}. + /// + internal static string Cleanup_DropDuplicatesFailed { + get { + return ResourceManager.GetString("Cleanup_DropDuplicatesFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to fix authorship: {0}. + /// + internal static string Cleanup_FixAuthorFailed { + get { + return ResourceManager.GetString("Cleanup_FixAuthorFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found {0} commits to linearize.... + /// + internal static string Cleanup_FoundCommits { + get { + return ResourceManager.GetString("Cleanup_FoundCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Linearization complete. + /// + internal static string Cleanup_LinearizeComplete { + get { + return ResourceManager.GetString("Cleanup_LinearizeComplete", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to linearize history: {0}. + /// + internal static string Cleanup_LinearizeFailed { + get { + return ResourceManager.GetString("Cleanup_LinearizeFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Linearizing {0} commits (removing {1} merges).... + /// + internal static string Cleanup_Linearizing { + get { + return ResourceManager.GetString("Cleanup_Linearizing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Need at least 2 commits to squash. + /// + internal static string Cleanup_NeedTwoCommits { + get { + return ResourceManager.GetString("Cleanup_NeedTwoCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No commits found on current branch. + /// + internal static string Cleanup_NoCommitsOnBranch { + get { + return ResourceManager.GetString("Cleanup_NoCommitsOnBranch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No commits specified to fix. + /// + internal static string Cleanup_NoCommitsToFix { + get { + return ResourceManager.GetString("Cleanup_NoCommitsToFix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No commits specified to squash. + /// + internal static string Cleanup_NoCommitsToSquash { + get { + return ResourceManager.GetString("Cleanup_NoCommitsToSquash", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No fix commits to consolidate. + /// + internal static string Cleanup_NoFixCommits { + get { + return ResourceManager.GetString("Cleanup_NoFixCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No matching commits found to drop. + /// + internal static string Cleanup_NoMatchingCommits { + get { + return ResourceManager.GetString("Cleanup_NoMatchingCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No matching fix commits found to consolidate. + /// + internal static string Cleanup_NoMatchingFixes { + get { + return ResourceManager.GetString("Cleanup_NoMatchingFixes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No matching merge commits found to squash. + /// + internal static string Cleanup_NoMergeCommits { + get { + return ResourceManager.GetString("Cleanup_NoMergeCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cleanup type '{0}' is not yet implemented. + /// + internal static string Cleanup_NotImplemented { + get { + return ResourceManager.GetString("Cleanup_NotImplemented", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Processing branch {0}.... + /// + internal static string Cleanup_ProcessingBranch { + get { + return ResourceManager.GetString("Cleanup_ProcessingBranch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Processing commit {0}/{1}.... + /// + internal static string Cleanup_ProcessingCommit { + get { + return ResourceManager.GetString("Cleanup_ProcessingCommit", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Some commits have been pushed. Enable 'AllowPushedCommits' to proceed.. + /// + internal static string Cleanup_PushedCommitsBlocked { + get { + return ResourceManager.GetString("Cleanup_PushedCommitsBlocked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rebuilding commit history.... + /// + internal static string Cleanup_Rebuilding { + get { + return ResourceManager.GetString("Cleanup_Rebuilding", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rebuilding commit {0}/{1}.... + /// + internal static string Cleanup_RebuildingCommit { + get { + return ResourceManager.GetString("Cleanup_RebuildingCommit", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rebuilding {0} commits.... + /// + internal static string Cleanup_RebuildingCount { + get { + return ResourceManager.GetString("Cleanup_RebuildingCount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reconcile: merge final state after linearization. + /// + internal static string Cleanup_ReconcileMerge { + get { + return ResourceManager.GetString("Cleanup_ReconcileMerge", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reconciling final state.... + /// + internal static string Cleanup_Reconciling { + get { + return ResourceManager.GetString("Cleanup_Reconciling", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Squashing merge commits.... + /// + internal static string Cleanup_SquashingMerges { + get { + return ResourceManager.GetString("Cleanup_SquashingMerges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to squash merge commits: {0}. + /// + internal static string Cleanup_SquashMergeFailed { + get { + return ResourceManager.GetString("Cleanup_SquashMergeFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Updating branch reference.... + /// + internal static string Cleanup_UpdatingBranch { + get { + return ResourceManager.GetString("Cleanup_UpdatingBranch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to run git command: {0}. + /// + internal static string Git_CommandFailed { + get { + return ResourceManager.GetString("Git_CommandFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Commit not found: {0}. + /// + internal static string Git_CommitNotFound { + get { + return ResourceManager.GetString("Git_CommitNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Git error: {0}. + /// + internal static string Git_Error { + get { + return ResourceManager.GetString("Git_Error", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Force pushed to origin/{0}. + /// + internal static string Git_ForcePushedTo { + get { + return ResourceManager.GetString("Git_ForcePushedTo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Force push successful. + /// + internal static string Git_ForcePushSuccess { + get { + return ResourceManager.GetString("Git_ForcePushSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Force push successful (via git command). + /// + internal static string Git_ForcePushSuccessCmd { + get { + return ResourceManager.GetString("Git_ForcePushSuccessCmd", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to update HEAD to new commit {0}. + /// + internal static string Git_HeadUpdateFailed { + get { + return ResourceManager.GetString("Git_HeadUpdateFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No commits in repository. + /// + internal static string Git_NoCommits { + get { + return ResourceManager.GetString("Git_NoCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Push rejected: non-fast-forward. Pull changes first or use force push.. + /// + internal static string Git_NonFastForward { + get { + return ResourceManager.GetString("Git_NonFastForward", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Target commit is not an ancestor of HEAD. + /// + internal static string Git_NotAncestor { + get { + return ResourceManager.GetString("Git_NotAncestor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not find any target commits in repository. + /// + internal static string Git_NoTargetCommits { + get { + return ResourceManager.GetString("Git_NoTargetCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No upstream branch configured. Set tracking with: git push -u origin <branch>. + /// + internal static string Git_NoUpstream { + get { + return ResourceManager.GetString("Git_NoUpstream", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No upstream branch configured and no 'origin' remote found. + /// + ///Set tracking manually with: git push -u origin {0}. + /// + internal static string Git_NoUpstreamNoOrigin { + get { + return ResourceManager.GetString("Git_NoUpstreamNoOrigin", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Old commit {0} still reachable from HEAD after rewrite. + /// + internal static string Git_OldCommitReachable { + get { + return ResourceManager.GetString("Git_OldCommitReachable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Commit creation failed: parent mismatch for commit {0}. + /// + internal static string Git_ParentMismatch { + get { + return ResourceManager.GetString("Git_ParentMismatch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to start git process. + /// + internal static string Git_ProcessFailed { + get { + return ResourceManager.GetString("Git_ProcessFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Push failed: {0}. + /// + internal static string Git_PushFailed { + get { + return ResourceManager.GetString("Git_PushFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Push successful (via git command). + /// + internal static string Git_PushSuccessCmd { + get { + return ResourceManager.GetString("Git_PushSuccessCmd", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remote '{0}' not found. + /// + internal static string Git_RemoteNotFound { + get { + return ResourceManager.GetString("Git_RemoteNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disk verification failed: HEAD should be {0} but is {1}. + /// + internal static string Git_VerificationFailed { + get { + return ResourceManager.GetString("Git_VerificationFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Analyzing authorship. + /// + internal static string Health_AnalyzingAuthorship { + get { + return ResourceManager.GetString("Health_AnalyzingAuthorship", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Analyzing branch complexity. + /// + internal static string Health_AnalyzingBranches { + get { + return ResourceManager.GetString("Health_AnalyzingBranches", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Analyzing merge commits. + /// + internal static string Health_AnalyzingMerges { + get { + return ResourceManager.GetString("Health_AnalyzingMerges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Analyzing message quality. + /// + internal static string Health_AnalyzingMessages { + get { + return ResourceManager.GetString("Health_AnalyzingMessages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Complete. + /// + internal static string Health_Complete { + get { + return ResourceManager.GetString("Health_Complete", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Detecting duplicates. + /// + internal static string Health_DetectingDuplicates { + get { + return ResourceManager.GetString("Health_DetectingDuplicates", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Loading commits. + /// + internal static string Health_LoadingCommits { + get { + return ResourceManager.GetString("Health_LoadingCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository requires immediate attention. History is severely degraded.. + /// + internal static string HealthStatus_Critical { + get { + return ResourceManager.GetString("HealthStatus_Critical", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository has noticeable issues that should be addressed.. + /// + internal static string HealthStatus_NeedsAttention { + get { + return ResourceManager.GetString("HealthStatus_NeedsAttention", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cross-merges between branches. + /// + internal static string Report_CrossMerges { + get { + return ResourceManager.GetString("Report_CrossMerges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Detected {0} cross-merges between feature branches. Use feature branches that only merge into main.. + /// + internal static string Report_CrossMergesDesc { + get { + return ResourceManager.GetString("Report_CrossMergesDesc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicate commits with identical content. + /// + internal static string Report_DuplicateContent { + get { + return ResourceManager.GetString("Report_DuplicateContent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found {0} groups of commits with identical file content ({1} redundant commits). These are safe to squash as they have the same tree SHA.. + /// + internal static string Report_DuplicateContentDesc { + get { + return ResourceManager.GetString("Report_DuplicateContentDesc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Commits with duplicate messages. + /// + internal static string Report_DuplicateMessages { + get { + return ResourceManager.GetString("Report_DuplicateMessages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found {0} groups of commits with identical messages but different code changes ({1} commits). Consider using more descriptive messages to differentiate changes.. + /// + internal static string Report_DuplicateMessagesDesc { + get { + return ResourceManager.GetString("Report_DuplicateMessagesDesc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Excessive merge commits. + /// + internal static string Report_ExcessiveMerges { + get { + return ResourceManager.GetString("Report_ExcessiveMerges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to High merge commit ratio. + /// + internal static string Report_HighMergeRatio { + get { + return ResourceManager.GetString("Report_HighMergeRatio", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Merge fix commits detected. + /// + internal static string Report_MergeFixCommits { + get { + return ResourceManager.GetString("Report_MergeFixCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found {0} commits with messages like 'fix merge' detected after merges.. + /// + internal static string Report_MergeFixDesc { + get { + return ResourceManager.GetString("Report_MergeFixDesc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your repository has a {0}% merge commit ratio ({1}/{2} commits). Consider using rebase workflow or squash merges.. + /// + internal static string Report_MergeRatioDesc { + get { + return ResourceManager.GetString("Report_MergeRatioDesc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Stale branches. + /// + internal static string Report_StaleBranches { + get { + return ResourceManager.GetString("Report_StaleBranches", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found {0} branches with no activity in 30+ days.. + /// + internal static string Report_StaleBranchesDesc { + get { + return ResourceManager.GetString("Report_StaleBranchesDesc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your branch is {0} commit(s) behind the remote. Consider pulling first to avoid conflicts.. + /// + internal static string Safety_BehindRemote { + get { + return ResourceManager.GetString("Safety_BehindRemote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} commit(s) have already been pushed to the remote. Rewriting them will require a force push and may affect collaborators.. + /// + internal static string Safety_PushedCommits { + get { + return ResourceManager.GetString("Safety_PushedCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have uncommitted changes. Please commit or stash them first.. + /// + internal static string Safety_UncommittedChanges { + get { + return ResourceManager.GetString("Safety_UncommittedChanges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to AI analysis failed. + /// + internal static string Service_AiAnalysisFailed { + get { + return ResourceManager.GetString("Service_AiAnalysisFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to AI did not return structured output - fell back to original message. + /// + internal static string Service_AiFallback { + get { + return ResourceManager.GetString("Service_AiFallback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to API key is not configured. Please set your API key in Settings.. + /// + internal static string Service_ApiKeyNotConfigured { + get { + return ResourceManager.GetString("Service_ApiKeyNotConfigured", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No suggested message available. + /// + internal static string Service_NoSuggestion { + get { + return ResourceManager.GetString("Service_NoSuggestion", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Push successful. + /// + internal static string Service_PushSuccess { + get { + return ResourceManager.GetString("Service_PushSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository not found: {0}. + /// + internal static string Service_RepoNotFound { + get { + return ResourceManager.GetString("Service_RepoNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository not registered. + /// + internal static string Service_RepoNotRegistered { + get { + return ResourceManager.GetString("Service_RepoNotRegistered", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository not registered: {0}. + /// + internal static string Service_RepoNotRegisteredPath { + get { + return ResourceManager.GetString("Service_RepoNotRegisteredPath", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot rewrite commits with uncommitted changes. Please commit or stash your changes first.. + /// + internal static string Service_UncommittedChanges { + get { + return ResourceManager.GetString("Service_UncommittedChanges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown error. + /// + internal static string Service_UnknownError { + get { + return ResourceManager.GetString("Service_UnknownError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ai options cannot be null. + /// + internal static string Validation_AiOptionsNull { + get { + return ResourceManager.GetString("Validation_AiOptionsNull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid GitImproverOptions: {0}. + /// + internal static string Validation_InvalidOptions { + get { + return ResourceManager.GetString("Validation_InvalidOptions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MaxCommitsPerRepo must be greater than 0. + /// + internal static string Validation_MaxCommitsPositive { + get { + return ResourceManager.GetString("Validation_MaxCommitsPositive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rules cannot be null. + /// + internal static string Validation_RulesNull { + get { + return ResourceManager.GetString("Validation_RulesNull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Weights must sum to 1.0 (current: {0}). + /// + internal static string Validation_WeightsSum { + get { + return ResourceManager.GetString("Validation_WeightsSum", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to WorkspaceRoot directory does not exist: {0}. + /// + internal static string Validation_WorkspaceNotFound { + get { + return ResourceManager.GetString("Validation_WorkspaceNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to WorkspaceRoot is required. + /// + internal static string Validation_WorkspaceRequired { + get { + return ResourceManager.GetString("Validation_WorkspaceRequired", resourceCulture); + } + } + } +} diff --git a/Resources/Strings/LibStrings.de.resx b/Resources/Strings/LibStrings.de.resx new file mode 100644 index 0000000..973b7b6 --- /dev/null +++ b/Resources/Strings/LibStrings.de.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Commit-Nachricht ist leer + + + Betreff hat {0} Zeichen, Minimum ist {1} + + + Betreff hat {0} Zeichen, empfohlenes Maximum ist {1} + + + Betreff verwendet nicht-aussagekräftige Formulierung: '{0}' + + + Nachricht folgt nicht dem konventionellen Commit-Format (typ: betreff) + + + Unbekannter konventioneller Commit-Typ: {0} + + + Keine Issue-Referenz gefunden (z.B. #123 oder JIRA-123) + + + Betreff sollte mit einem Großbuchstaben beginnen + + + Betreff sollte nicht mit einem Punkt enden + + + Verwenden Sie den Imperativ: '{0}' → '{1}' (z.B. 'Add' nicht 'Added') + + + Textkörper hat {0} Zeichen, Minimum ist {1} + + + Fügen Sie eine Leerzeile zwischen Betreff und Textkörper ein + + + '{0}' beschreibt nicht, was in {1} Dateien geändert wurde + + + Nachricht ist zu vage für {0} geänderte Dateien - beschreiben Sie WAS sich geändert hat + + + Große Änderung ({0} Dateien, {1} Zeilen) verdient eine aussagekräftigere Nachricht + + + Größere Änderung ({0} Dateien) sollte einen Textkörper enthalten, der das Warum erklärt + + + Erwägen Sie zu erwähnen, welcher Bereich sich geändert hat (Dateien: {0}) + + + + + Keine Commits im Repository + + + Commit nicht gefunden: {0} + + + Ziel-Commit ist kein Vorgänger von HEAD + + + Konnte keine Ziel-Commits im Repository finden + + + Commit-Erstellung fehlgeschlagen: Eltern-Konflikt für Commit {0} + + + HEAD konnte nicht auf neuen Commit {0} aktualisiert werden + + + Datenträger-Verifizierung fehlgeschlagen: HEAD sollte {0} sein, ist aber {1} + + + Alter Commit {0} ist nach dem Umschreiben immer noch von HEAD aus erreichbar + + + Git-Fehler: {0} + + + Remote '{0}' nicht gefunden + + + Kein Upstream-Branch konfiguriert und kein 'origin' Remote gefunden. + +Setzen Sie das Tracking manuell mit: git push -u origin {0} + + + Force-Push erfolgreich + + + Force-Push zu origin/{0} erfolgreich + + + Git-Prozess konnte nicht gestartet werden + + + Force-Push erfolgreich (über Git-Befehl) + + + Push fehlgeschlagen: {0} + + + Git-Befehl konnte nicht ausgeführt werden: {0} + + + Kein Upstream-Branch konfiguriert. Setzen Sie das Tracking mit: git push -u origin <branch> + + + Push abgelehnt: non-fast-forward. Ziehen Sie zuerst Änderungen oder verwenden Sie Force-Push. + + + Push erfolgreich (über Git-Befehl) + + + + + Einige Commits wurden bereits gepusht. Aktivieren Sie 'AllowPushedCommits', um fortzufahren. + + + Bereinigungstyp '{0}' ist noch nicht implementiert + + + Commit-Historie wird neu aufgebaut... + + + {0} Commits werden neu aufgebaut... + + + Commit {0}/{1} wird verarbeitet... + + + Branch-Referenz wird aktualisiert... + + + Merge-Commits werden zusammengeführt... + + + Duplikat-Commits konnten nicht entfernt werden: {0} + + + Benötige mindestens 2 Commits zum Zusammenführen + + + Keine Commits auf dem aktuellen Branch gefunden + + + Keine passenden Commits zum Entfernen gefunden + + + Keine Commits zum Zusammenführen angegeben + + + Keine passenden Merge-Commits zum Zusammenführen gefunden + + + Merge-Commits konnten nicht zusammengeführt werden: {0} + + + Keine Commits zum Korrigieren angegeben + + + Autorenschaft konnte nicht korrigiert werden: {0} + + + Merge-Fix-Commits werden konsolidiert... + + + Keine Fix-Commits zum Konsolidieren + + + Keine passenden Fix-Commits zum Konsolidieren gefunden + + + Merge-Fix-Commits konnten nicht konsolidiert werden: {0} + + + Veraltete Branches werden archiviert... + + + Branch {0} wird verarbeitet... + + + Archivierung abgeschlossen + + + Branches konnten nicht archiviert werden: {0} + + + Branch-Struktur wird analysiert... + + + {0} Commits zum Linearisieren gefunden... + + + {0} Commits werden linearisiert ({1} Merges werden entfernt)... + + + Commit {0}/{1} wird neu aufgebaut... + + + Endzustand wird abgeglichen... + + + Historie ist bereits linear - keine Merge-Commits gefunden + + + Linearisierung abgeschlossen + + + Historie konnte nicht linearisiert werden: {0} + + + Es werden {0} Commit-Nachricht(en) umformuliert, um die Qualität zu verbessern. + + + Es werden {0} doppelte Commits zu 1 zusammengeführt. + + + Es werden {0} Merge-Fix-Commits konsolidiert. + + + Autorenschaft wird bei {0} Commit(s) korrigiert. + + + Es werden {0} triviale Merges konsolidiert. + + + Veraltete Branches werden archiviert (löschen wenn gemergt, sonst taggen). + + + Historie wird durch Entfernen von Merge-Commits und Sortierung nach Datum linearisiert. + + + Es werden {0} Commit(s) verarbeitet. + + + Abgleich: Endzustand nach Linearisierung mergen + + + + + WorkspaceRoot ist erforderlich + + + WorkspaceRoot-Verzeichnis existiert nicht: {0} + + + MaxCommitsPerRepo muss größer als 0 sein + + + Regeln dürfen nicht null sein + + + KI-Optionen dürfen nicht null sein + + + Ungültige GitImproverOptions: {0} + + + Gewichtungen müssen sich zu 1,0 summieren (aktuell: {0}) + + + + + Unbekannter Fehler + + + Repository nicht registriert + + + Commits können nicht umgeschrieben werden, wenn es nicht committete Änderungen gibt. Bitte committen oder stashen Sie Ihre Änderungen zuerst. + + + Repository nicht gefunden: {0} + + + Keine vorgeschlagene Nachricht verfügbar + + + Repository nicht registriert: {0} + + + API-Schlüssel ist nicht konfiguriert. Bitte setzen Sie Ihren API-Schlüssel in den Einstellungen. + + + KI-Analyse fehlgeschlagen + + + KI hat keine strukturierte Ausgabe zurückgegeben - auf ursprüngliche Nachricht zurückgefallen + + + Push erfolgreich + + + + + Commits werden geladen + + + Duplikate werden erkannt + + + Merge-Commits werden analysiert + + + Branch-Komplexität wird analysiert + + + Nachrichtenqualität wird analysiert + + + Autorenschaft wird analysiert + + + Abgeschlossen + + + + + Doppelte Commits mit identischem Inhalt + + + {0} Gruppen von Commits mit identischem Dateiinhalt gefunden ({1} redundante Commits). Diese können sicher zusammengeführt werden, da sie denselben Tree-SHA haben. + + + Commits mit doppelten Nachrichten + + + {0} Gruppen von Commits mit identischen Nachrichten aber unterschiedlichen Code-Änderungen gefunden ({1} Commits). Erwägen Sie aussagekräftigere Nachrichten zur Unterscheidung der Änderungen. + + + Übermäßig viele Merge-Commits + + + Hoher Merge-Commit-Anteil + + + Ihr Repository hat einen Merge-Commit-Anteil von {0}% ({1}/{2} Commits). Erwägen Sie einen Rebase-Workflow oder Squash-Merges. + + + Merge-Fix-Commits erkannt + + + {0} Commits mit Nachrichten wie 'fix merge' nach Merges erkannt. + + + Cross-Merges zwischen Branches + + + {0} Cross-Merges zwischen Feature-Branches erkannt. Verwenden Sie Feature-Branches, die nur in main gemergt werden. + + + Veraltete Branches + + + {0} Branches ohne Aktivität in den letzten 30+ Tagen gefunden. + + + + + Sie haben nicht committete Änderungen. Bitte committen oder stashen Sie diese zuerst. + + + {0} Commit(s) wurden bereits auf das Remote gepusht. Das Umschreiben erfordert einen Force-Push und kann Mitarbeiter beeinträchtigen. + + + Ihr Branch ist {0} Commit(s) hinter dem Remote zurück. Erwägen Sie zuerst zu pullen, um Konflikte zu vermeiden. + + + + + Repository hat erkennbare Probleme, die behoben werden sollten. + + + Repository erfordert sofortige Aufmerksamkeit. Historie ist schwer beschädigt. + + + diff --git a/Resources/Strings/LibStrings.es.resx b/Resources/Strings/LibStrings.es.resx new file mode 100644 index 0000000..dfbfc34 --- /dev/null +++ b/Resources/Strings/LibStrings.es.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + El mensaje del commit está vacío + + + El asunto tiene {0} caracteres, el mínimo es {1} + + + El asunto tiene {0} caracteres, el máximo recomendado es {1} + + + El asunto usa una frase poco descriptiva: '{0}' + + + El mensaje no sigue el formato conventional commit (tipo: asunto) + + + Tipo de conventional commit desconocido: {0} + + + No se encontró referencia a issue (ej., #123 o JIRA-123) + + + El asunto debe comenzar con letra mayúscula + + + El asunto no debe terminar con punto + + + Use modo imperativo: '{0}' → '{1}' (ej., 'Add' no 'Added') + + + El cuerpo tiene {0} caracteres, el mínimo es {1} + + + Agregue una línea en blanco entre el asunto y el cuerpo + + + '{0}' no describe lo que cambió en {1} archivos + + + El mensaje es demasiado vago para {0} archivos modificados - describa QUÉ cambió + + + Cambio grande ({0} archivos, {1} líneas) merece un mensaje más descriptivo + + + Cambio importante ({0} archivos) debe incluir un cuerpo explicando por qué + + + Considere mencionar qué área cambió (archivos: {0}) + + + + + No hay commits en el repositorio + + + Commit no encontrado: {0} + + + El commit objetivo no es un ancestro de HEAD + + + No se pudieron encontrar commits objetivo en el repositorio + + + Falló la creación del commit: discrepancia de parent para el commit {0} + + + No se pudo actualizar HEAD al nuevo commit {0} + + + Falló la verificación en disco: HEAD debería ser {0} pero es {1} + + + El commit antiguo {0} sigue siendo alcanzable desde HEAD después de la reescritura + + + Error de git: {0} + + + Remote '{0}' no encontrado + + + No hay branch upstream configurado y no se encontró remote 'origin'. + +Configure el seguimiento manualmente con: git push -u origin {0} + + + Force push exitoso + + + Force push realizado a origin/{0} + + + No se pudo iniciar el proceso de git + + + Force push exitoso (vía comando git) + + + Push falló: {0} + + + No se pudo ejecutar el comando git: {0} + + + No hay branch upstream configurado. Configure el seguimiento con: git push -u origin <branch> + + + Push rechazado: non-fast-forward. Obtenga los cambios primero o use force push. + + + Push exitoso (vía comando git) + + + + + Algunos commits han sido pusheados. Habilite 'AllowPushedCommits' para continuar. + + + El tipo de limpieza '{0}' aún no está implementado + + + Reconstruyendo historial de commits... + + + Reconstruyendo {0} commits... + + + Procesando commit {0}/{1}... + + + Actualizando referencia del branch... + + + Realizando squash de merge commits... + + + No se pudieron eliminar commits duplicados: {0} + + + Se necesitan al menos 2 commits para hacer squash + + + No se encontraron commits en el branch actual + + + No se encontraron commits coincidentes para eliminar + + + No se especificaron commits para hacer squash + + + No se encontraron merge commits coincidentes para hacer squash + + + No se pudo hacer squash de merge commits: {0} + + + No se especificaron commits para corregir + + + No se pudo corregir la autoría: {0} + + + Consolidando commits de corrección de merge... + + + No hay commits de corrección para consolidar + + + No se encontraron commits de corrección coincidentes para consolidar + + + No se pudieron consolidar commits de corrección de merge: {0} + + + Archivando branches obsoletos... + + + Procesando branch {0}... + + + Archivo completado + + + No se pudieron archivar branches: {0} + + + Analizando estructura del branch... + + + Se encontraron {0} commits para linearizar... + + + Linearizando {0} commits (eliminando {1} merges)... + + + Reconstruyendo commit {0}/{1}... + + + Reconciliando estado final... + + + El historial ya es lineal - no se encontraron merge commits + + + Linearización completada + + + No se pudo linearizar el historial: {0} + + + Se reescribirán {0} mensaje(s) de commit para mejorar la calidad. + + + Se hará squash de {0} commits duplicados en 1. + + + Se consolidarán {0} commits de corrección de merge. + + + Se corregirá la autoría en {0} commit(s). + + + Se consolidarán {0} merges triviales. + + + Se archivarán branches obsoletos (eliminar si están merged, etiquetar en caso contrario). + + + Se linearizará el historial eliminando merge commits y ordenando por fecha. + + + Se procesarán {0} commit(s). + + + Reconciliar: merge del estado final después de la linearización + + + + + WorkspaceRoot es requerido + + + El directorio WorkspaceRoot no existe: {0} + + + MaxCommitsPerRepo debe ser mayor que 0 + + + Rules no puede ser null + + + Las opciones Ai no pueden ser null + + + GitImproverOptions inválidas: {0} + + + Los pesos deben sumar 1.0 (actual: {0}) + + + + + Error desconocido + + + Repositorio no registrado + + + No se pueden reescribir commits con cambios sin commitear. Por favor, haga commit o stash de sus cambios primero. + + + Repositorio no encontrado: {0} + + + No hay mensaje sugerido disponible + + + Repositorio no registrado: {0} + + + La clave API no está configurada. Por favor, configure su clave API en Configuración. + + + Falló el análisis de IA + + + La IA no devolvió salida estructurada - se retornó al mensaje original + + + Push exitoso + + + + + Cargando commits + + + Detectando duplicados + + + Analizando merge commits + + + Analizando complejidad de branches + + + Analizando calidad de mensajes + + + Analizando autoría + + + Completado + + + + + Commits duplicados con contenido idéntico + + + Se encontraron {0} grupos de commits con contenido de archivo idéntico ({1} commits redundantes). Es seguro hacer squash ya que tienen el mismo tree SHA. + + + Commits con mensajes duplicados + + + Se encontraron {0} grupos de commits con mensajes idénticos pero cambios de código diferentes ({1} commits). Considere usar mensajes más descriptivos para diferenciar los cambios. + + + Merge commits excesivos + + + Proporción alta de merge commits + + + Su repositorio tiene una proporción de {0}% de merge commits ({1}/{2} commits). Considere usar flujo de trabajo de rebase o squash merges. + + + Commits de corrección de merge detectados + + + Se encontraron {0} commits con mensajes como 'fix merge' detectados después de merges. + + + Cross-merges entre branches + + + Se detectaron {0} cross-merges entre feature branches. Use feature branches que solo hagan merge a main. + + + Branches obsoletos + + + Se encontraron {0} branches sin actividad en más de 30 días. + + + + + Tiene cambios sin commitear. Por favor, haga commit o stash de ellos primero. + + + {0} commit(s) ya han sido pusheados al remote. Reescribirlos requerirá un force push y puede afectar a colaboradores. + + + Su branch está {0} commit(s) detrás del remote. Considere hacer pull primero para evitar conflictos. + + + + + El repositorio tiene problemas notables que deben abordarse. + + + El repositorio requiere atención inmediata. El historial está severamente degradado. + + + diff --git a/Resources/Strings/LibStrings.fr.resx b/Resources/Strings/LibStrings.fr.resx new file mode 100644 index 0000000..5bfd990 --- /dev/null +++ b/Resources/Strings/LibStrings.fr.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Le message de commit est vide + + + Le sujet contient {0} caractères, le minimum est {1} + + + Le sujet contient {0} caractères, le maximum recommandé est {1} + + + Le sujet utilise une expression non descriptive : '{0}' + + + Le message ne suit pas le format de commit conventionnel (type: sujet) + + + Type de commit conventionnel inconnu : {0} + + + Aucune référence de ticket trouvée (par exemple, #123 ou JIRA-123) + + + Le sujet devrait commencer par une majuscule + + + Le sujet ne devrait pas se terminer par un point + + + Utilisez le mode impératif : '{0}' → '{1}' (par exemple, 'Ajoute' et non 'Ajouté') + + + Le corps contient {0} caractères, le minimum est {1} + + + Ajoutez une ligne vide entre le sujet et le corps + + + '{0}' ne décrit pas ce qui a changé dans {1} fichiers + + + Le message est trop vague pour {0} fichiers modifiés - décrivez CE QUI a changé + + + Un changement important ({0} fichiers, {1} lignes) mérite un message plus descriptif + + + Un changement majeur ({0} fichiers) devrait inclure un corps expliquant pourquoi + + + Envisagez de mentionner la zone modifiée (fichiers : {0}) + + + + + Aucun commit dans le dépôt + + + Commit introuvable : {0} + + + Le commit cible n'est pas un ancêtre de HEAD + + + Impossible de trouver des commits cibles dans le dépôt + + + Échec de la création du commit : incohérence de parent pour le commit {0} + + + Échec de la mise à jour de HEAD vers le nouveau commit {0} + + + Échec de la vérification du disque : HEAD devrait être {0} mais est {1} + + + L'ancien commit {0} est toujours accessible depuis HEAD après la réécriture + + + Erreur Git : {0} + + + Dépôt distant '{0}' introuvable + + + Aucune branche amont configurée et aucun dépôt distant 'origin' trouvé. + +Définissez le suivi manuellement avec : git push -u origin {0} + + + Push forcé réussi + + + Push forcé vers origin/{0} + + + Échec du démarrage du processus git + + + Push forcé réussi (via commande git) + + + Échec du push : {0} + + + Échec de l'exécution de la commande git : {0} + + + Aucune branche amont configurée. Définissez le suivi avec : git push -u origin <branch> + + + Push rejeté : non-fast-forward. Récupérez d'abord les modifications ou utilisez le push forcé. + + + Push réussi (via commande git) + + + + + Certains commits ont été poussés. Activez 'AllowPushedCommits' pour continuer. + + + Le type de nettoyage '{0}' n'est pas encore implémenté + + + Reconstruction de l'historique des commits... + + + Reconstruction de {0} commits... + + + Traitement du commit {0}/{1}... + + + Mise à jour de la référence de branche... + + + Écrasement des commits de merge... + + + Échec de la suppression des commits en double : {0} + + + Au moins 2 commits sont nécessaires pour écraser + + + Aucun commit trouvé sur la branche actuelle + + + Aucun commit correspondant trouvé à supprimer + + + Aucun commit spécifié à écraser + + + Aucun commit de merge correspondant trouvé à écraser + + + Échec de l'écrasement des commits de merge : {0} + + + Aucun commit spécifié à corriger + + + Échec de la correction de la paternité : {0} + + + Consolidation des commits de correction de merge... + + + Aucun commit de correction à consolider + + + Aucun commit de correction correspondant trouvé à consolider + + + Échec de la consolidation des commits de correction de merge : {0} + + + Archivage des branches obsolètes... + + + Traitement de la branche {0}... + + + Archivage terminé + + + Échec de l'archivage des branches : {0} + + + Analyse de la structure des branches... + + + {0} commits trouvés à linéariser... + + + Linéarisation de {0} commits (suppression de {1} merges)... + + + Reconstruction du commit {0}/{1}... + + + Réconciliation de l'état final... + + + L'historique est déjà linéaire - aucun commit de merge trouvé + + + Linéarisation terminée + + + Échec de la linéarisation de l'historique : {0} + + + Reformulera {0} message(s) de commit pour améliorer la qualité. + + + Écrasera {0} commits en double en 1. + + + Consolidera {0} commits de correction de merge. + + + Corrigera la paternité sur {0} commit(s). + + + Consolidera {0} merges triviaux. + + + Archivera les branches obsolètes (suppression si fusionnées, étiquetage sinon). + + + Linéarisera l'historique en supprimant les commits de merge et en triant par date. + + + Traitera {0} commit(s). + + + Réconciliation : fusion de l'état final après linéarisation + + + + + WorkspaceRoot est requis + + + Le répertoire WorkspaceRoot n'existe pas : {0} + + + MaxCommitsPerRepo doit être supérieur à 0 + + + Rules ne peut pas être null + + + Les options Ai ne peuvent pas être null + + + GitImproverOptions invalides : {0} + + + Les poids doivent totaliser 1.0 (actuel : {0}) + + + + + Erreur inconnue + + + Dépôt non enregistré + + + Impossible de réécrire les commits avec des modifications non validées. Veuillez d'abord valider ou mettre en réserve vos modifications. + + + Dépôt introuvable : {0} + + + Aucun message suggéré disponible + + + Dépôt non enregistré : {0} + + + La clé API n'est pas configurée. Veuillez définir votre clé API dans les Paramètres. + + + Échec de l'analyse IA + + + L'IA n'a pas retourné de sortie structurée - retour au message original + + + Push réussi + + + + + Chargement des commits + + + Détection des doublons + + + Analyse des commits de merge + + + Analyse de la complexité des branches + + + Analyse de la qualité des messages + + + Analyse de la paternité + + + Terminé + + + + + Commits en double avec un contenu identique + + + {0} groupes de commits avec un contenu de fichier identique trouvés ({1} commits redondants). Ceux-ci peuvent être écrasés en toute sécurité car ils ont le même SHA d'arborescence. + + + Commits avec des messages en double + + + {0} groupes de commits avec des messages identiques mais des modifications de code différentes trouvés ({1} commits). Envisagez d'utiliser des messages plus descriptifs pour différencier les changements. + + + Commits de merge excessifs + + + Ratio élevé de commits de merge + + + Votre dépôt a un ratio de commits de merge de {0}% ({1}/{2} commits). Envisagez d'utiliser le flux de travail rebase ou les merges écrasés. + + + Commits de correction de merge détectés + + + {0} commits avec des messages du type 'fix merge' détectés après des merges. + + + Cross-merges entre branches + + + {0} cross-merges entre branches de fonctionnalité détectés. Utilisez des branches de fonctionnalité qui fusionnent uniquement dans main. + + + Branches obsolètes + + + {0} branches sans activité depuis plus de 30 jours trouvées. + + + + + Vous avez des modifications non validées. Veuillez d'abord les valider ou les mettre en réserve. + + + {0} commit(s) ont déjà été poussés vers le dépôt distant. Les réécrire nécessitera un push forcé et pourrait affecter les collaborateurs. + + + Votre branche est en retard de {0} commit(s) par rapport au dépôt distant. Envisagez de faire un pull d'abord pour éviter les conflits. + + + + + Le dépôt présente des problèmes notables qui devraient être traités. + + + Le dépôt nécessite une attention immédiate. L'historique est gravement dégradé. + + + diff --git a/Resources/Strings/LibStrings.hi.resx b/Resources/Strings/LibStrings.hi.resx new file mode 100644 index 0000000..2fce2cb --- /dev/null +++ b/Resources/Strings/LibStrings.hi.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Commit संदेश खाली है + + + विषय {0} वर्णों का है, न्यूनतम {1} होना चाहिए + + + विषय {0} वर्णों का है, अनुशंसित अधिकतम {1} है + + + विषय में गैर-वर्णनात्मक वाक्यांश का उपयोग: '{0}' + + + संदेश conventional commit प्रारूप का पालन नहीं करता (type: subject) + + + अज्ञात conventional commit प्रकार: {0} + + + कोई issue संदर्भ नहीं मिला (जैसे, #123 या JIRA-123) + + + विषय बड़े अक्षर से शुरू होना चाहिए + + + विषय पूर्ण विराम के साथ समाप्त नहीं होना चाहिए + + + आज्ञार्थक मूड का उपयोग करें: '{0}' → '{1}' (जैसे, 'Add' न कि 'Added') + + + मुख्य भाग {0} वर्णों का है, न्यूनतम {1} होना चाहिए + + + विषय और मुख्य भाग के बीच एक रिक्त पंक्ति जोड़ें + + + '{0}' यह वर्णन नहीं करता कि {1} फ़ाइलों में क्या बदला + + + संदेश {0} बदली गई फ़ाइलों के लिए बहुत अस्पष्ट है - वर्णन करें कि क्या बदला + + + बड़े परिवर्तन ({0} फ़ाइलें, {1} पंक्तियाँ) के लिए अधिक विस्तृत संदेश की आवश्यकता है + + + प्रमुख परिवर्तन ({0} फ़ाइलें) में मुख्य भाग शामिल होना चाहिए जो बताए कि क्यों + + + किस क्षेत्र में परिवर्तन हुआ इसका उल्लेख करने पर विचार करें (फ़ाइलें: {0}) + + + + + Repository में कोई commit नहीं + + + Commit नहीं मिला: {0} + + + लक्ष्य commit HEAD का पूर्वज नहीं है + + + Repository में कोई लक्ष्य commit नहीं मिला + + + Commit निर्माण विफल: commit {0} के लिए parent बेमेल + + + नए commit {0} में HEAD अपडेट करना विफल + + + डिस्क सत्यापन विफल: HEAD {0} होना चाहिए लेकिन {1} है + + + पुराना commit {0} rewrite के बाद भी HEAD से पहुंचने योग्य है + + + Git त्रुटि: {0} + + + Remote '{0}' नहीं मिला + + + कोई upstream branch कॉन्फ़िगर नहीं है और कोई 'origin' remote नहीं मिला। + +इसके साथ मैन्युअल रूप से tracking सेट करें: git push -u origin {0} + + + Force push सफल + + + origin/{0} में force push किया गया + + + Git प्रक्रिया शुरू करना विफल + + + Force push सफल (git command के माध्यम से) + + + Push विफल: {0} + + + Git command चलाना विफल: {0} + + + कोई upstream branch कॉन्फ़िगर नहीं है। इसके साथ tracking सेट करें: git push -u origin <branch> + + + Push अस्वीकृत: non-fast-forward। पहले परिवर्तन pull करें या force push का उपयोग करें। + + + Push सफल (git command के माध्यम से) + + + + + कुछ commits push किए जा चुके हैं। आगे बढ़ने के लिए 'AllowPushedCommits' सक्षम करें। + + + Cleanup प्रकार '{0}' अभी तक लागू नहीं किया गया है + + + Commit इतिहास का पुनर्निर्माण हो रहा है... + + + {0} commits का पुनर्निर्माण हो रहा है... + + + Commit {0}/{1} प्रोसेस हो रहा है... + + + Branch संदर्भ अपडेट हो रहा है... + + + Merge commits squash हो रहे हैं... + + + डुप्लिकेट commits हटाना विफल: {0} + + + Squash करने के लिए कम से कम 2 commits की आवश्यकता है + + + वर्तमान branch पर कोई commit नहीं मिला + + + हटाने के लिए कोई मिलान करने वाले commits नहीं मिले + + + Squash करने के लिए कोई commits निर्दिष्ट नहीं + + + Squash करने के लिए कोई मिलान करने वाले merge commits नहीं मिले + + + Merge commits squash करना विफल: {0} + + + ठीक करने के लिए कोई commits निर्दिष्ट नहीं + + + Authorship ठीक करना विफल: {0} + + + Merge fix commits समेकित हो रहे हैं... + + + समेकित करने के लिए कोई fix commits नहीं + + + समेकित करने के लिए कोई मिलान करने वाले fix commits नहीं मिले + + + Merge fix commits समेकित करना विफल: {0} + + + पुरानी branches संग्रहीत हो रही हैं... + + + Branch {0} प्रोसेस हो रही है... + + + संग्रह पूर्ण + + + Branches संग्रहीत करना विफल: {0} + + + Branch संरचना का विश्लेषण हो रहा है... + + + रैखिक बनाने के लिए {0} commits मिले... + + + {0} commits रैखिक हो रहे हैं ({1} merges हटाए जा रहे हैं)... + + + Commit {0}/{1} का पुनर्निर्माण हो रहा है... + + + अंतिम स्थिति का समाधान हो रहा है... + + + इतिहास पहले से ही रैखिक है - कोई merge commits नहीं मिले + + + रैखिकरण पूर्ण + + + इतिहास रैखिक बनाना विफल: {0} + + + गुणवत्ता सुधारने के लिए {0} commit संदेश(शों) को फिर से शब्दित किया जाएगा। + + + {0} डुप्लिकेट commits को 1 में squash किया जाएगा। + + + {0} merge-fix commits समेकित किए जाएंगे। + + + {0} commit(s) पर authorship ठीक की जाएगी। + + + {0} तुच्छ merges समेकित किए जाएंगे। + + + पुरानी branches संग्रहीत की जाएंगी (merge होने पर हटाएं, अन्यथा tag करें)। + + + Merge commits हटाकर और तारीख के अनुसार क्रमबद्ध करके इतिहास रैखिक किया जाएगा। + + + {0} commit(s) प्रोसेस किए जाएंगे। + + + समाधान: रैखिकरण के बाद अंतिम स्थिति merge करें + + + + + WorkspaceRoot आवश्यक है + + + WorkspaceRoot निर्देशिका मौजूद नहीं है: {0} + + + MaxCommitsPerRepo 0 से अधिक होना चाहिए + + + Rules null नहीं हो सकते + + + Ai options null नहीं हो सकते + + + अमान्य GitImproverOptions: {0} + + + Weights का योग 1.0 होना चाहिए (वर्तमान: {0}) + + + + + अज्ञात त्रुटि + + + Repository पंजीकृत नहीं है + + + असंबद्ध परिवर्तनों के साथ commits को फिर से नहीं लिखा जा सकता। कृपया पहले अपने परिवर्तनों को commit या stash करें। + + + Repository नहीं मिली: {0} + + + कोई सुझाया गया संदेश उपलब्ध नहीं है + + + Repository पंजीकृत नहीं है: {0} + + + API key कॉन्फ़िगर नहीं है। कृपया सेटिंग्स में अपनी API key सेट करें। + + + AI विश्लेषण विफल + + + AI ने संरचित आउटपुट वापस नहीं किया - मूल संदेश पर वापस आ गए + + + Push सफल + + + + + Commits लोड हो रहे हैं + + + डुप्लिकेट का पता लगाया जा रहा है + + + Merge commits का विश्लेषण हो रहा है + + + Branch जटिलता का विश्लेषण हो रहा है + + + संदेश गुणवत्ता का विश्लेषण हो रहा है + + + Authorship का विश्लेषण हो रहा है + + + पूर्ण + + + + + समान सामग्री वाले डुप्लिकेट commits + + + समान फ़ाइल सामग्री वाले commits के {0} समूह मिले ({1} अनावश्यक commits)। इन्हें squash करना सुरक्षित है क्योंकि इनका tree SHA समान है। + + + डुप्लिकेट संदेशों वाले commits + + + समान संदेशों लेकिन विभिन्न कोड परिवर्तनों वाले commits के {0} समूह मिले ({1} commits)। परिवर्तनों को अलग करने के लिए अधिक वर्णनात्मक संदेशों का उपयोग करने पर विचार करें। + + + अत्यधिक merge commits + + + उच्च merge commit अनुपात + + + आपकी repository में {0}% merge commit अनुपात है ({1}/{2} commits)। Rebase workflow या squash merges का उपयोग करने पर विचार करें। + + + Merge fix commits का पता चला + + + Merges के बाद 'fix merge' जैसे संदेशों वाले {0} commits मिले। + + + Branches के बीच cross-merges + + + Feature branches के बीच {0} cross-merges का पता चला। केवल main में merge होने वाली feature branches का उपयोग करें। + + + पुरानी branches + + + 30+ दिनों में कोई गतिविधि नहीं वाली {0} branches मिलीं। + + + + + आपके पास असंबद्ध परिवर्तन हैं। कृपया पहले उन्हें commit या stash करें। + + + {0} commit(s) पहले ही remote में push किए जा चुके हैं। उन्हें फिर से लिखने के लिए force push की आवश्यकता होगी और यह सहयोगियों को प्रभावित कर सकता है। + + + आपकी branch remote से {0} commit(s) पीछे है। संघर्षों से बचने के लिए पहले pull करने पर विचार करें। + + + + + Repository में ध्यान देने योग्य समस्याएं हैं जिन्हें हल किया जाना चाहिए। + + + Repository को तत्काल ध्यान देने की आवश्यकता है। इतिहास गंभीर रूप से क्षतिग्रस्त है। + + + diff --git a/Resources/Strings/LibStrings.it.resx b/Resources/Strings/LibStrings.it.resx new file mode 100644 index 0000000..1298969 --- /dev/null +++ b/Resources/Strings/LibStrings.it.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Il messaggio del commit è vuoto + + + L'oggetto è di {0} caratteri, il minimo è {1} + + + L'oggetto è di {0} caratteri, il massimo consigliato è {1} + + + L'oggetto usa una frase non descrittiva: '{0}' + + + Il messaggio non segue il formato conventional commit (tipo: oggetto) + + + Tipo di conventional commit sconosciuto: {0} + + + Nessun riferimento a issue trovato (es. #123 o JIRA-123) + + + L'oggetto dovrebbe iniziare con una lettera maiuscola + + + L'oggetto non dovrebbe terminare con un punto + + + Usa l'imperativo: '{0}' → '{1}' (es. 'Add' non 'Added') + + + Il corpo è di {0} caratteri, il minimo è {1} + + + Aggiungi una riga vuota tra oggetto e corpo + + + '{0}' non descrive cosa è cambiato in {1} file + + + Il messaggio è troppo vago per {0} file modificati - descrivi COSA è cambiato + + + Un cambiamento importante ({0} file, {1} righe) merita un messaggio più descrittivo + + + Un cambiamento importante ({0} file) dovrebbe includere un corpo che spiega il perché + + + Considera di menzionare quale area è cambiata (file: {0}) + + + + + Nessun commit nel repository + + + Commit non trovato: {0} + + + Il commit di destinazione non è un antenato di HEAD + + + Impossibile trovare commit di destinazione nel repository + + + Creazione commit fallita: mancata corrispondenza del genitore per il commit {0} + + + Aggiornamento di HEAD al nuovo commit {0} fallito + + + Verifica disco fallita: HEAD dovrebbe essere {0} ma è {1} + + + Il vecchio commit {0} è ancora raggiungibile da HEAD dopo la riscrittura + + + Errore git: {0} + + + Remote '{0}' non trovato + + + Nessun branch upstream configurato e nessun remote 'origin' trovato. + +Imposta il tracking manualmente con: git push -u origin {0} + + + Force push eseguito con successo + + + Force push eseguito su origin/{0} + + + Impossibile avviare il processo git + + + Force push eseguito con successo (tramite comando git) + + + Push fallito: {0} + + + Esecuzione comando git fallita: {0} + + + Nessun branch upstream configurato. Imposta il tracking con: git push -u origin <branch> + + + Push rifiutato: non fast-forward. Esegui pull delle modifiche prima o usa force push. + + + Push eseguito con successo (tramite comando git) + + + + + Alcuni commit sono stati pubblicati. Abilita 'AllowPushedCommits' per procedere. + + + Il tipo di pulizia '{0}' non è ancora implementato + + + Ricostruzione della cronologia dei commit... + + + Ricostruzione di {0} commit... + + + Elaborazione commit {0}/{1}... + + + Aggiornamento del riferimento al branch... + + + Squash dei commit di merge... + + + Eliminazione dei commit duplicati fallita: {0} + + + Servono almeno 2 commit per eseguire lo squash + + + Nessun commit trovato sul branch corrente + + + Nessun commit corrispondente trovato da eliminare + + + Nessun commit specificato per lo squash + + + Nessun commit di merge corrispondente trovato per lo squash + + + Squash dei commit di merge fallito: {0} + + + Nessun commit specificato da correggere + + + Correzione dell'autore fallita: {0} + + + Consolidamento dei commit di correzione merge... + + + Nessun commit di correzione da consolidare + + + Nessun commit di correzione corrispondente trovato da consolidare + + + Consolidamento dei commit di correzione merge fallito: {0} + + + Archiviazione dei branch obsoleti... + + + Elaborazione branch {0}... + + + Archiviazione completata + + + Archiviazione dei branch fallita: {0} + + + Analisi della struttura del branch... + + + Trovati {0} commit da linearizzare... + + + Linearizzazione di {0} commit (rimozione di {1} merge)... + + + Ricostruzione commit {0}/{1}... + + + Riconciliazione dello stato finale... + + + La cronologia è già lineare - nessun commit di merge trovato + + + Linearizzazione completata + + + Linearizzazione della cronologia fallita: {0} + + + Verrà riformulato il messaggio di {0} commit per migliorarne la qualità. + + + Verranno uniti {0} commit duplicati in 1. + + + Verranno consolidati {0} commit di correzione merge. + + + Verrà corretta l'autorialità su {0} commit. + + + Verranno consolidati {0} merge banali. + + + Verranno archiviati i branch obsoleti (eliminati se merged, altrimenti taggati). + + + Verrà linearizzata la cronologia rimuovendo i commit di merge e ordinando per data. + + + Verranno elaborati {0} commit. + + + Riconciliazione: merge dello stato finale dopo la linearizzazione + + + + + WorkspaceRoot è obbligatorio + + + La directory WorkspaceRoot non esiste: {0} + + + MaxCommitsPerRepo deve essere maggiore di 0 + + + Rules non può essere null + + + Le opzioni Ai non possono essere null + + + GitImproverOptions non valide: {0} + + + I pesi devono sommare a 1.0 (corrente: {0}) + + + + + Errore sconosciuto + + + Repository non registrato + + + Impossibile riscrivere i commit con modifiche non salvate. Esegui prima il commit o lo stash delle modifiche. + + + Repository non trovato: {0} + + + Nessun messaggio suggerito disponibile + + + Repository non registrato: {0} + + + La chiave API non è configurata. Imposta la tua chiave API nelle Impostazioni. + + + Analisi AI fallita + + + L'AI non ha restituito un output strutturato - ripiegato sul messaggio originale + + + Push eseguito con successo + + + + + Caricamento commit + + + Rilevamento duplicati + + + Analisi dei commit di merge + + + Analisi della complessità dei branch + + + Analisi della qualità dei messaggi + + + Analisi dell'autorialità + + + Completato + + + + + Commit duplicati con contenuto identico + + + Trovati {0} gruppi di commit con contenuto di file identico ({1} commit ridondanti). È sicuro eseguire lo squash poiché hanno lo stesso SHA dell'albero. + + + Commit con messaggi duplicati + + + Trovati {0} gruppi di commit con messaggi identici ma modifiche al codice diverse ({1} commit). Considera l'uso di messaggi più descrittivi per differenziare le modifiche. + + + Commit di merge eccessivi + + + Rapporto elevato di commit di merge + + + Il tuo repository ha un rapporto di commit di merge del {0}% ({1}/{2} commit). Considera l'uso del workflow rebase o dello squash merge. + + + Rilevati commit di correzione merge + + + Trovati {0} commit con messaggi come 'fix merge' rilevati dopo i merge. + + + Cross-merge tra branch + + + Rilevati {0} cross-merge tra branch di funzionalità. Usa branch di funzionalità che si uniscono solo al main. + + + Branch obsoleti + + + Trovati {0} branch senza attività negli ultimi 30+ giorni. + + + + + Hai modifiche non salvate. Esegui prima il commit o lo stash. + + + {0} commit sono già stati pubblicati sul remote. Riscriverli richiederà un force push e potrebbe influire sui collaboratori. + + + Il tuo branch è {0} commit indietro rispetto al remote. Considera di eseguire pull prima per evitare conflitti. + + + + + Il repository presenta problemi evidenti che dovrebbero essere risolti. + + + Il repository richiede attenzione immediata. La cronologia è gravemente degradata. + + + diff --git a/Resources/Strings/LibStrings.ja.resx b/Resources/Strings/LibStrings.ja.resx new file mode 100644 index 0000000..9c2e196 --- /dev/null +++ b/Resources/Strings/LibStrings.ja.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + commitメッセージが空です + + + 件名が{0}文字です。最小値は{1}文字です + + + 件名が{0}文字です。推奨最大値は{1}文字です + + + 件名に説明不足のフレーズが使用されています: '{0}' + + + メッセージがconventional commit形式に従っていません (type: subject) + + + 不明なconventional commitタイプ: {0} + + + 課題参照が見つかりません (例: #123 または JIRA-123) + + + 件名は大文字で始める必要があります + + + 件名の末尾にピリオドを付けないでください + + + 命令形を使用してください: '{0}' → '{1}' (例: 'Add' であって 'Added' ではありません) + + + 本文が{0}文字です。最小値は{1}文字です + + + 件名と本文の間に空白行を追加してください + + + '{0}'は{1}個のファイルで何が変更されたかを説明していません + + + 変更された{0}個のファイルに対してメッセージが曖昧すぎます - 何が変更されたかを説明してください + + + 大規模な変更 ({0}個のファイル、{1}行) にはより詳細なメッセージが必要です + + + 主要な変更 ({0}個のファイル) には理由を説明する本文を含める必要があります + + + 変更された領域の記載を検討してください (ファイル: {0}) + + + + + リポジトリにcommitがありません + + + commitが見つかりません: {0} + + + 対象のcommitはHEADの祖先ではありません + + + リポジトリで対象のcommitが見つかりませんでした + + + commit作成に失敗しました: commit {0}の親が一致しません + + + HEADを新しいcommit {0}に更新できませんでした + + + ディスク検証に失敗しました: HEADは{0}であるべきですが、{1}です + + + 書き換え後、古いcommit {0}がHEADから到達可能です + + + Gitエラー: {0} + + + リモート'{0}'が見つかりません + + + upstreamブランチが設定されておらず、'origin'リモートも見つかりません。 + +次のコマンドで手動で追跡を設定してください: git push -u origin {0} + + + force pushが成功しました + + + origin/{0}にforce pushしました + + + gitプロセスの起動に失敗しました + + + force pushが成功しました (gitコマンド経由) + + + pushに失敗しました: {0} + + + gitコマンドの実行に失敗しました: {0} + + + upstreamブランチが設定されていません。次のコマンドで追跡を設定してください: git push -u origin <branch> + + + pushが拒否されました: non-fast-forward。先に変更をpullするか、force pushを使用してください。 + + + pushが成功しました (gitコマンド経由) + + + + + 一部のcommitがpushされています。続行するには'AllowPushedCommits'を有効にしてください。 + + + クリーンアップタイプ'{0}'はまだ実装されていません + + + commit履歴を再構築しています... + + + {0}個のcommitを再構築しています... + + + commit {0}/{1}を処理しています... + + + branch参照を更新しています... + + + merge commitをsquashしています... + + + 重複commitの削除に失敗しました: {0} + + + squashするには少なくとも2つのcommitが必要です + + + 現在のbranchにcommitが見つかりません + + + 削除する一致するcommitが見つかりません + + + squashするcommitが指定されていません + + + squashする一致するmerge commitが見つかりません + + + merge commitのsquashに失敗しました: {0} + + + 修正するcommitが指定されていません + + + 作成者の修正に失敗しました: {0} + + + merge修正commitを統合しています... + + + 統合する修正commitがありません + + + 統合する一致する修正commitが見つかりません + + + merge修正commitの統合に失敗しました: {0} + + + 古いbranchをアーカイブしています... + + + branch {0}を処理しています... + + + アーカイブが完了しました + + + branchのアーカイブに失敗しました: {0} + + + branch構造を分析しています... + + + 線形化する{0}個のcommitが見つかりました... + + + {0}個のcommitを線形化しています ({1}個のmergeを削除)... + + + commit {0}/{1}を再構築しています... + + + 最終状態を調整しています... + + + 履歴はすでに線形です - merge commitが見つかりません + + + 線形化が完了しました + + + 履歴の線形化に失敗しました: {0} + + + 品質向上のため、{0}個のcommitメッセージを書き直します。 + + + {0}個の重複commitを1つにsquashします。 + + + {0}個のmerge修正commitを統合します。 + + + {0}個のcommitの作成者を修正します。 + + + {0}個の些細なmergeを統合します。 + + + 古いbranchをアーカイブします (mergeされている場合は削除、そうでない場合はタグ付け)。 + + + merge commitを削除し、日付順にソートして履歴を線形化します。 + + + {0}個のcommitを処理します。 + + + 調整: 線形化後の最終状態をmerge + + + + + WorkspaceRootは必須です + + + WorkspaceRootディレクトリが存在しません: {0} + + + MaxCommitsPerRepoは0より大きい値である必要があります + + + Rulesをnullにすることはできません + + + Aiオプションをnullにすることはできません + + + 無効なGitImproverOptions: {0} + + + 重みの合計は1.0である必要があります (現在: {0}) + + + + + 不明なエラー + + + リポジトリが登録されていません + + + コミットされていない変更があるため、commitを書き換えることができません。先に変更をcommitまたはstashしてください。 + + + リポジトリが見つかりません: {0} + + + 提案されたメッセージがありません + + + リポジトリが登録されていません: {0} + + + APIキーが設定されていません。設定でAPIキーを設定してください。 + + + AI分析に失敗しました + + + AIが構造化された出力を返しませんでした - 元のメッセージにフォールバックしました + + + pushが成功しました + + + + + commitを読み込んでいます + + + 重複を検出しています + + + merge commitを分析しています + + + branchの複雑さを分析しています + + + メッセージの品質を分析しています + + + 作成者を分析しています + + + 完了 + + + + + 内容が同一の重複commit + + + 同一のファイル内容を持つcommitのグループが{0}個見つかりました ({1}個の冗長なcommit)。これらは同じツリーSHAを持つため、安全にsquashできます。 + + + 重複したメッセージを持つcommit + + + 同一のメッセージを持つが、異なるコード変更があるcommitのグループが{0}個見つかりました ({1}個のcommit)。変更を区別するために、より説明的なメッセージの使用を検討してください。 + + + 過剰なmerge commit + + + merge commitの比率が高い + + + リポジトリのmerge commit比率が{0}%です ({1}/{2}個のcommit)。rebaseワークフローまたはsquash mergeの使用を検討してください。 + + + merge修正commitが検出されました + + + merge後に'fix merge'のようなメッセージを持つcommitが{0}個見つかりました。 + + + branch間のクロスmerge + + + 機能branch間のクロスmergeが{0}個検出されました。mainにのみmergeする機能branchを使用してください。 + + + 古いbranch + + + 30日以上活動がないbranchが{0}個見つかりました。 + + + + + コミットされていない変更があります。先にcommitまたはstashしてください。 + + + {0}個のcommitがすでにリモートにpushされています。これらを書き換えるにはforce pushが必要で、共同作業者に影響を与える可能性があります。 + + + branchがリモートより{0}個のcommit分遅れています。競合を避けるため、先にpullすることを検討してください。 + + + + + リポジトリに対処すべき目立った問題があります。 + + + リポジトリは緊急の対応が必要です。履歴が著しく劣化しています。 + + + diff --git a/Resources/Strings/LibStrings.nl.resx b/Resources/Strings/LibStrings.nl.resx new file mode 100644 index 0000000..ae65e06 --- /dev/null +++ b/Resources/Strings/LibStrings.nl.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Commit-bericht is leeg + + + Onderwerp is {0} tekens, minimum is {1} + + + Onderwerp is {0} tekens, aanbevolen maximum is {1} + + + Onderwerp gebruikt niet-beschrijvende zin: '{0}' + + + Bericht volgt niet het conventionele commit-formaat (type: onderwerp) + + + Onbekend conventioneel commit-type: {0} + + + Geen issue-referentie gevonden (bijv. #123 of JIRA-123) + + + Onderwerp moet beginnen met een hoofdletter + + + Onderwerp mag niet eindigen met een punt + + + Gebruik gebiedende wijs: '{0}' → '{1}' (bijv. 'Voeg toe' niet 'Toegevoegd') + + + Body is {0} tekens, minimum is {1} + + + Voeg een lege regel toe tussen onderwerp en body + + + '{0}' beschrijft niet wat er is gewijzigd in {1} bestanden + + + Bericht is te vaag voor {0} gewijzigde bestanden - beschrijf WAT er is gewijzigd + + + Grote wijziging ({0} bestanden, {1} regels) verdient een meer beschrijvend bericht + + + Grote wijziging ({0} bestanden) moet een body bevatten die uitlegt waarom + + + Overweeg te vermelden welk gebied is gewijzigd (bestanden: {0}) + + + + + Geen commits in repository + + + Commit niet gevonden: {0} + + + Doel-commit is geen voorouder van HEAD + + + Kon geen doel-commits vinden in repository + + + Aanmaken van commit mislukt: parent komt niet overeen voor commit {0} + + + Bijwerken van HEAD naar nieuwe commit {0} mislukt + + + Schijfverificatie mislukt: HEAD zou {0} moeten zijn maar is {1} + + + Oude commit {0} nog steeds bereikbaar vanaf HEAD na herschrijven + + + Git-fout: {0} + + + Remote '{0}' niet gevonden + + + Geen upstream branch geconfigureerd en geen 'origin' remote gevonden. + +Stel tracking handmatig in met: git push -u origin {0} + + + Force push succesvol + + + Force push naar origin/{0} uitgevoerd + + + Starten van git-proces mislukt + + + Force push succesvol (via git-commando) + + + Push mislukt: {0} + + + Uitvoeren van git-commando mislukt: {0} + + + Geen upstream branch geconfigureerd. Stel tracking in met: git push -u origin <branch> + + + Push geweigerd: non-fast-forward. Haal eerst wijzigingen op of gebruik force push. + + + Push succesvol (via git-commando) + + + + + Sommige commits zijn gepusht. Schakel 'AllowPushedCommits' in om door te gaan. + + + Opruimtype '{0}' is nog niet geïmplementeerd + + + Commit-geschiedenis wordt opnieuw opgebouwd... + + + {0} commits worden opnieuw opgebouwd... + + + Commit {0}/{1} wordt verwerkt... + + + Branch-referentie wordt bijgewerkt... + + + Merge-commits worden samengevoegd... + + + Verwijderen van dubbele commits mislukt: {0} + + + Minimaal 2 commits nodig om samen te voegen + + + Geen commits gevonden op huidige branch + + + Geen overeenkomende commits gevonden om te verwijderen + + + Geen commits opgegeven om samen te voegen + + + Geen overeenkomende merge-commits gevonden om samen te voegen + + + Samenvoegen van merge-commits mislukt: {0} + + + Geen commits opgegeven om te corrigeren + + + Corrigeren van auteurschap mislukt: {0} + + + Merge-fix commits worden geconsolideerd... + + + Geen fix-commits om te consolideren + + + Geen overeenkomende fix-commits gevonden om te consolideren + + + Consolideren van merge-fix commits mislukt: {0} + + + Verouderde branches worden gearchiveerd... + + + Branch {0} wordt verwerkt... + + + Archivering voltooid + + + Archiveren van branches mislukt: {0} + + + Branch-structuur wordt geanalyseerd... + + + {0} commits gevonden om te lineariseren... + + + {0} commits worden gelineariseerd ({1} merges worden verwijderd)... + + + Commit {0}/{1} wordt opnieuw opgebouwd... + + + Eindstaat wordt gereconcilieerd... + + + Geschiedenis is al lineair - geen merge-commits gevonden + + + Linearisatie voltooid + + + Lineariseren van geschiedenis mislukt: {0} + + + {0} commit-bericht(en) zullen worden herschreven om de kwaliteit te verbeteren. + + + {0} dubbele commits zullen worden samengevoegd tot 1. + + + {0} merge-fix commits zullen worden geconsolideerd. + + + Auteurschap zal worden gecorrigeerd op {0} commit(s). + + + {0} triviale merges zullen worden geconsolideerd. + + + Verouderde branches zullen worden gearchiveerd (verwijderen indien gemerged, anders taggen). + + + Geschiedenis zal worden gelineariseerd door merge-commits te verwijderen en op datum te sorteren. + + + {0} commit(s) zullen worden verwerkt. + + + Reconcile: eindstaat samenvoegen na linearisatie + + + + + WorkspaceRoot is verplicht + + + WorkspaceRoot-map bestaat niet: {0} + + + MaxCommitsPerRepo moet groter zijn dan 0 + + + Rules kunnen niet null zijn + + + Ai-opties kunnen niet null zijn + + + Ongeldige GitImproverOptions: {0} + + + Gewichten moeten optellen tot 1.0 (huidig: {0}) + + + + + Onbekende fout + + + Repository niet geregistreerd + + + Kan commits niet herschrijven met niet-gecommitte wijzigingen. Commit of stash eerst uw wijzigingen. + + + Repository niet gevonden: {0} + + + Geen gesuggereerd bericht beschikbaar + + + Repository niet geregistreerd: {0} + + + API-sleutel is niet geconfigureerd. Stel uw API-sleutel in via Instellingen. + + + AI-analyse mislukt + + + AI heeft geen gestructureerde output gegeven - teruggevallen op origineel bericht + + + Push succesvol + + + + + Commits worden geladen + + + Duplicaten worden gedetecteerd + + + Merge-commits worden geanalyseerd + + + Branch-complexiteit wordt geanalyseerd + + + Berichtkwaliteit wordt geanalyseerd + + + Auteurschap wordt geanalyseerd + + + Voltooid + + + + + Dubbele commits met identieke inhoud + + + {0} groepen van commits gevonden met identieke bestandsinhoud ({1} redundante commits). Deze kunnen veilig worden samengevoegd omdat ze dezelfde tree SHA hebben. + + + Commits met dubbele berichten + + + {0} groepen van commits gevonden met identieke berichten maar verschillende codewijzigingen ({1} commits). Overweeg meer beschrijvende berichten te gebruiken om wijzigingen te onderscheiden. + + + Buitensporig veel merge-commits + + + Hoge merge-commit ratio + + + Uw repository heeft een {0}% merge-commit ratio ({1}/{2} commits). Overweeg een rebase-workflow of squash merges te gebruiken. + + + Merge-fix commits gedetecteerd + + + {0} commits gevonden met berichten zoals 'fix merge' gedetecteerd na merges. + + + Cross-merges tussen branches + + + {0} cross-merges tussen feature branches gedetecteerd. Gebruik feature branches die alleen naar main mergen. + + + Verouderde branches + + + {0} branches gevonden zonder activiteit in 30+ dagen. + + + + + U heeft niet-gecommitte wijzigingen. Commit of stash ze eerst. + + + {0} commit(s) zijn al gepusht naar de remote. Het herschrijven ervan vereist een force push en kan andere medewerkers beïnvloeden. + + + Uw branch loopt {0} commit(s) achter op de remote. Overweeg eerst te pullen om conflicten te voorkomen. + + + + + Repository heeft merkbare problemen die moeten worden aangepakt. + + + Repository vereist onmiddellijke aandacht. Geschiedenis is ernstig gedegradeerd. + + + diff --git a/Resources/Strings/LibStrings.pt.resx b/Resources/Strings/LibStrings.pt.resx new file mode 100644 index 0000000..dd8f92a --- /dev/null +++ b/Resources/Strings/LibStrings.pt.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Mensagem de commit está vazia + + + Assunto tem {0} caracteres, mínimo é {1} + + + Assunto tem {0} caracteres, máximo recomendado é {1} + + + Assunto usa frase não descritiva: '{0}' + + + Mensagem não segue o formato de commit convencional (tipo: assunto) + + + Tipo de commit convencional desconhecido: {0} + + + Nenhuma referência a issue encontrada (ex: #123 ou JIRA-123) + + + Assunto deve começar com letra maiúscula + + + Assunto não deve terminar com ponto final + + + Use modo imperativo: '{0}' → '{1}' (ex: 'Add' não 'Added') + + + Corpo tem {0} caracteres, mínimo é {1} + + + Adicione uma linha em branco entre o assunto e o corpo + + + '{0}' não descreve o que mudou em {1} arquivos + + + Mensagem muito vaga para {0} arquivos alterados - descreva O QUE mudou + + + Mudança grande ({0} arquivos, {1} linhas) merece uma mensagem mais descritiva + + + Mudança importante ({0} arquivos) deve incluir um corpo explicando o porquê + + + Considere mencionar qual área mudou (arquivos: {0}) + + + + + Nenhum commit no repositório + + + Commit não encontrado: {0} + + + Commit de destino não é um ancestral de HEAD + + + Não foi possível encontrar commits de destino no repositório + + + Falha na criação do commit: incompatibilidade de pai para o commit {0} + + + Falha ao atualizar HEAD para o novo commit {0} + + + Falha na verificação do disco: HEAD deveria ser {0} mas é {1} + + + Commit antigo {0} ainda acessível a partir de HEAD após reescrita + + + Erro do Git: {0} + + + Remoto '{0}' não encontrado + + + Nenhum branch upstream configurado e nenhum remoto 'origin' encontrado. + +Configure o rastreamento manualmente com: git push -u origin {0} + + + Force push bem-sucedido + + + Force push realizado para origin/{0} + + + Falha ao iniciar o processo git + + + Force push bem-sucedido (via comando git) + + + Push falhou: {0} + + + Falha ao executar comando git: {0} + + + Nenhum branch upstream configurado. Configure o rastreamento com: git push -u origin <branch> + + + Push rejeitado: non-fast-forward. Faça pull das mudanças primeiro ou use force push. + + + Push bem-sucedido (via comando git) + + + + + Alguns commits já foram enviados. Habilite 'AllowPushedCommits' para prosseguir. + + + Tipo de limpeza '{0}' ainda não foi implementado + + + Reconstruindo histórico de commits... + + + Reconstruindo {0} commits... + + + Processando commit {0}/{1}... + + + Atualizando referência do branch... + + + Consolidando commits de merge... + + + Falha ao remover commits duplicados: {0} + + + São necessários pelo menos 2 commits para consolidar + + + Nenhum commit encontrado no branch atual + + + Nenhum commit correspondente encontrado para remover + + + Nenhum commit especificado para consolidar + + + Nenhum commit de merge correspondente encontrado para consolidar + + + Falha ao consolidar commits de merge: {0} + + + Nenhum commit especificado para corrigir + + + Falha ao corrigir autoria: {0} + + + Consolidando commits de correção de merge... + + + Nenhum commit de correção para consolidar + + + Nenhum commit de correção correspondente encontrado para consolidar + + + Falha ao consolidar commits de correção de merge: {0} + + + Arquivando branches obsoletos... + + + Processando branch {0}... + + + Arquivamento concluído + + + Falha ao arquivar branches: {0} + + + Analisando estrutura do branch... + + + Encontrados {0} commits para linearizar... + + + Linearizando {0} commits (removendo {1} merges)... + + + Reconstruindo commit {0}/{1}... + + + Reconciliando estado final... + + + Histórico já é linear - nenhum commit de merge encontrado + + + Linearização concluída + + + Falha ao linearizar histórico: {0} + + + Irá reescrever {0} mensagem(ns) de commit para melhorar a qualidade. + + + Irá consolidar {0} commits duplicados em 1. + + + Irá consolidar {0} commits de correção de merge. + + + Irá corrigir autoria em {0} commit(s). + + + Irá consolidar {0} merges triviais. + + + Irá arquivar branches obsoletos (excluir se mesclado, marcar com tag caso contrário). + + + Irá linearizar histórico removendo commits de merge e ordenando por data. + + + Irá processar {0} commit(s). + + + Reconciliar: mesclar estado final após linearização + + + + + WorkspaceRoot é obrigatório + + + Diretório WorkspaceRoot não existe: {0} + + + MaxCommitsPerRepo deve ser maior que 0 + + + Rules não pode ser nulo + + + Opções de Ai não podem ser nulas + + + GitImproverOptions inválido: {0} + + + Pesos devem somar 1.0 (atual: {0}) + + + + + Erro desconhecido + + + Repositório não registrado + + + Não é possível reescrever commits com alterações não confirmadas. Por favor, faça commit ou stash das suas alterações primeiro. + + + Repositório não encontrado: {0} + + + Nenhuma mensagem sugerida disponível + + + Repositório não registrado: {0} + + + Chave de API não está configurada. Por favor, defina sua chave de API nas Configurações. + + + Falha na análise de IA + + + IA não retornou saída estruturada - revertido para mensagem original + + + Push bem-sucedido + + + + + Carregando commits + + + Detectando duplicatas + + + Analisando commits de merge + + + Analisando complexidade dos branches + + + Analisando qualidade das mensagens + + + Analisando autoria + + + Concluído + + + + + Commits duplicados com conteúdo idêntico + + + Encontrados {0} grupos de commits com conteúdo de arquivo idêntico ({1} commits redundantes). É seguro consolidá-los pois têm o mesmo SHA de árvore. + + + Commits com mensagens duplicadas + + + Encontrados {0} grupos de commits com mensagens idênticas mas alterações de código diferentes ({1} commits). Considere usar mensagens mais descritivas para diferenciar as mudanças. + + + Commits de merge excessivos + + + Alta proporção de commits de merge + + + Seu repositório tem uma proporção de {0}% de commits de merge ({1}/{2} commits). Considere usar fluxo de trabalho com rebase ou squash merges. + + + Commits de correção de merge detectados + + + Encontrados {0} commits com mensagens como 'fix merge' detectados após merges. + + + Cross-merges entre branches + + + Detectados {0} cross-merges entre branches de funcionalidade. Use branches de funcionalidade que mesclam apenas para main. + + + Branches obsoletos + + + Encontrados {0} branches sem atividade em mais de 30 dias. + + + + + Você tem alterações não confirmadas. Por favor, faça commit ou stash delas primeiro. + + + {0} commit(s) já foram enviados para o remoto. Reescrevê-los exigirá um force push e pode afetar colaboradores. + + + Seu branch está {0} commit(s) atrás do remoto. Considere fazer pull primeiro para evitar conflitos. + + + + + Repositório tem problemas notáveis que devem ser abordados. + + + Repositório requer atenção imediata. Histórico está severamente degradado. + + + diff --git a/Resources/Strings/LibStrings.resx b/Resources/Strings/LibStrings.resx new file mode 100644 index 0000000..4ba5498 --- /dev/null +++ b/Resources/Strings/LibStrings.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Commit message is empty + + + Subject is {0} chars, minimum is {1} + + + Subject is {0} chars, recommended max is {1} + + + Subject uses non-descriptive phrase: '{0}' + + + Message doesn't follow conventional commit format (type: subject) + + + Unknown conventional commit type: {0} + + + No issue reference found (e.g., #123 or JIRA-123) + + + Subject should start with a capital letter + + + Subject should not end with a period + + + Use imperative mood: '{0}' → '{1}' (e.g., 'Add' not 'Added') + + + Body is {0} chars, minimum is {1} + + + Add a blank line between subject and body + + + '{0}' doesn't describe what changed in {1} files + + + Message is too vague for {0} changed files - describe WHAT changed + + + Large change ({0} files, {1} lines) deserves a more descriptive message + + + Major change ({0} files) should include a body explaining why + + + Consider mentioning what area changed (files: {0}) + + + + + No commits in repository + + + Commit not found: {0} + + + Target commit is not an ancestor of HEAD + + + Could not find any target commits in repository + + + Commit creation failed: parent mismatch for commit {0} + + + Failed to update HEAD to new commit {0} + + + Disk verification failed: HEAD should be {0} but is {1} + + + Old commit {0} still reachable from HEAD after rewrite + + + Git error: {0} + + + Remote '{0}' not found + + + No upstream branch configured and no 'origin' remote found. + +Set tracking manually with: git push -u origin {0} + + + Force push successful + + + Force pushed to origin/{0} + + + Failed to start git process + + + Force push successful (via git command) + + + Push failed: {0} + + + Failed to run git command: {0} + + + No upstream branch configured. Set tracking with: git push -u origin <branch> + + + Push rejected: non-fast-forward. Pull changes first or use force push. + + + Push successful (via git command) + + + + + Some commits have been pushed. Enable 'AllowPushedCommits' to proceed. + + + Cleanup type '{0}' is not yet implemented + + + Rebuilding commit history... + + + Rebuilding {0} commits... + + + Processing commit {0}/{1}... + + + Updating branch reference... + + + Squashing merge commits... + + + Failed to drop duplicate commits: {0} + + + Need at least 2 commits to squash + + + No commits found on current branch + + + No matching commits found to drop + + + No commits specified to squash + + + No matching merge commits found to squash + + + Failed to squash merge commits: {0} + + + No commits specified to fix + + + Failed to fix authorship: {0} + + + Consolidating merge fix commits... + + + No fix commits to consolidate + + + No matching fix commits found to consolidate + + + Failed to consolidate merge fix commits: {0} + + + Archiving stale branches... + + + Processing branch {0}... + + + Archive complete + + + Failed to archive branches: {0} + + + Analyzing branch structure... + + + Found {0} commits to linearize... + + + Linearizing {0} commits (removing {1} merges)... + + + Rebuilding commit {0}/{1}... + + + Reconciling final state... + + + History is already linear - no merge commits found + + + Linearization complete + + + Failed to linearize history: {0} + + + Will reword {0} commit message(s) to improve quality. + + + Will squash {0} duplicate commits into 1. + + + Will consolidate {0} merge-fix commits. + + + Will fix authorship on {0} commit(s). + + + Will consolidate {0} trivial merges. + + + Will archive stale branches (delete if merged, tag otherwise). + + + Will linearize history by removing merge commits and sorting by date. + + + Will process {0} commit(s). + + + Reconcile: merge final state after linearization + + + + + WorkspaceRoot is required + + + WorkspaceRoot directory does not exist: {0} + + + MaxCommitsPerRepo must be greater than 0 + + + Rules cannot be null + + + Ai options cannot be null + + + Invalid GitImproverOptions: {0} + + + Weights must sum to 1.0 (current: {0}) + + + + + Unknown error + + + Repository not registered + + + Cannot rewrite commits with uncommitted changes. Please commit or stash your changes first. + + + Repository not found: {0} + + + No suggested message available + + + Repository not registered: {0} + + + API key is not configured. Please set your API key in Settings. + + + AI analysis failed + + + AI did not return structured output - fell back to original message + + + Push successful + + + + + Loading commits + + + Detecting duplicates + + + Analyzing merge commits + + + Analyzing branch complexity + + + Analyzing message quality + + + Analyzing authorship + + + Complete + + + + + Duplicate commits with identical content + + + Found {0} groups of commits with identical file content ({1} redundant commits). These are safe to squash as they have the same tree SHA. + + + Commits with duplicate messages + + + Found {0} groups of commits with identical messages but different code changes ({1} commits). Consider using more descriptive messages to differentiate changes. + + + Excessive merge commits + + + High merge commit ratio + + + Your repository has a {0}% merge commit ratio ({1}/{2} commits). Consider using rebase workflow or squash merges. + + + Merge fix commits detected + + + Found {0} commits with messages like 'fix merge' detected after merges. + + + Cross-merges between branches + + + Detected {0} cross-merges between feature branches. Use feature branches that only merge into main. + + + Stale branches + + + Found {0} branches with no activity in 30+ days. + + + + + You have uncommitted changes. Please commit or stash them first. + + + {0} commit(s) have already been pushed to the remote. Rewriting them will require a force push and may affect collaborators. + + + Your branch is {0} commit(s) behind the remote. Consider pulling first to avoid conflicts. + + + + + Repository has noticeable issues that should be addressed. + + + Repository requires immediate attention. History is severely degraded. + + + diff --git a/Resources/Strings/LibStrings.ru.resx b/Resources/Strings/LibStrings.ru.resx new file mode 100644 index 0000000..e49378e --- /dev/null +++ b/Resources/Strings/LibStrings.ru.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Сообщение commit пустое + + + Заголовок содержит {0} символов, минимум {1} + + + Заголовок содержит {0} символов, рекомендуемый максимум {1} + + + Заголовок использует неинформативную фразу: '{0}' + + + Сообщение не соответствует формату conventional commit (тип: описание) + + + Неизвестный тип conventional commit: {0} + + + Не найдена ссылка на задачу (например, #123 или JIRA-123) + + + Заголовок должен начинаться с заглавной буквы + + + Заголовок не должен заканчиваться точкой + + + Используйте повелительное наклонение: '{0}' → '{1}' (например, 'Add' а не 'Added') + + + Тело содержит {0} символов, минимум {1} + + + Добавьте пустую строку между заголовком и телом + + + '{0}' не описывает изменения в {1} файлах + + + Сообщение слишком расплывчатое для {0} измененных файлов - опишите ЧТО изменилось + + + Большое изменение ({0} файлов, {1} строк) заслуживает более подробного описания + + + Значительное изменение ({0} файлов) должно включать тело с объяснением причин + + + Рассмотрите возможность указать, какая область изменилась (файлы: {0}) + + + + + В репозитории нет commit + + + Commit не найден: {0} + + + Целевой commit не является предком HEAD + + + Не удалось найти целевые commit в репозитории + + + Не удалось создать commit: несоответствие родителя для commit {0} + + + Не удалось обновить HEAD на новый commit {0} + + + Не удалось выполнить проверку на диске: HEAD должен быть {0}, но является {1} + + + Старый commit {0} все еще доступен из HEAD после перезаписи + + + Ошибка Git: {0} + + + Удаленный репозиторий '{0}' не найден + + + Upstream branch не настроен и удаленный репозиторий 'origin' не найден. + +Установите отслеживание вручную: git push -u origin {0} + + + Force push выполнен успешно + + + Force push выполнен в origin/{0} + + + Не удалось запустить процесс git + + + Force push выполнен успешно (через команду git) + + + Push не выполнен: {0} + + + Не удалось выполнить команду git: {0} + + + Upstream branch не настроен. Установите отслеживание: git push -u origin <branch> + + + Push отклонен: non-fast-forward. Сначала выполните pull изменений или используйте force push. + + + Push выполнен успешно (через команду git) + + + + + Некоторые commit были отправлены. Включите 'AllowPushedCommits' для продолжения. + + + Тип очистки '{0}' еще не реализован + + + Перестроение истории commit... + + + Перестроение {0} commit... + + + Обработка commit {0}/{1}... + + + Обновление ссылки на branch... + + + Объединение merge commit... + + + Не удалось удалить дублирующиеся commit: {0} + + + Для объединения требуется как минимум 2 commit + + + В текущей branch не найдено commit + + + Не найдено подходящих commit для удаления + + + Не указаны commit для объединения + + + Не найдено подходящих merge commit для объединения + + + Не удалось объединить merge commit: {0} + + + Не указаны commit для исправления + + + Не удалось исправить авторство: {0} + + + Консолидация commit с исправлениями merge... + + + Нет commit с исправлениями для консолидации + + + Не найдено подходящих commit с исправлениями для консолидации + + + Не удалось консолидировать commit с исправлениями merge: {0} + + + Архивирование устаревших branch... + + + Обработка branch {0}... + + + Архивирование завершено + + + Не удалось заархивировать branch: {0} + + + Анализ структуры branch... + + + Найдено {0} commit для линеаризации... + + + Линеаризация {0} commit (удаление {1} merge)... + + + Перестроение commit {0}/{1}... + + + Согласование окончательного состояния... + + + История уже линейна - merge commit не найдены + + + Линеаризация завершена + + + Не удалось линеаризовать историю: {0} + + + Будет переписано {0} сообщений commit для улучшения качества. + + + Будет объединено {0} дублирующихся commit в 1. + + + Будет консолидировано {0} commit с исправлениями merge. + + + Будет исправлено авторство в {0} commit. + + + Будет консолидировано {0} тривиальных merge. + + + Будут заархивированы устаревшие branch (удалены при слиянии, иначе помечены тегом). + + + Будет линеаризована история путем удаления merge commit и сортировки по дате. + + + Будет обработано {0} commit. + + + Согласование: merge окончательного состояния после линеаризации + + + + + WorkspaceRoot обязателен + + + Каталог WorkspaceRoot не существует: {0} + + + MaxCommitsPerRepo должен быть больше 0 + + + Rules не может быть null + + + Параметры Ai не могут быть null + + + Недопустимые GitImproverOptions: {0} + + + Сумма весов должна быть равна 1.0 (текущая: {0}) + + + + + Неизвестная ошибка + + + Репозиторий не зарегистрирован + + + Невозможно перезаписать commit с незафиксированными изменениями. Пожалуйста, сначала выполните commit или stash изменений. + + + Репозиторий не найден: {0} + + + Предлагаемое сообщение недоступно + + + Репозиторий не зарегистрирован: {0} + + + API ключ не настроен. Пожалуйста, установите API ключ в настройках. + + + Не удалось выполнить анализ AI + + + AI не вернул структурированный вывод - использовано исходное сообщение + + + Push выполнен успешно + + + + + Загрузка commit + + + Обнаружение дубликатов + + + Анализ merge commit + + + Анализ сложности branch + + + Анализ качества сообщений + + + Анализ авторства + + + Завершено + + + + + Дублирующиеся commit с идентичным содержимым + + + Найдено {0} групп commit с идентичным содержимым файлов ({1} избыточных commit). Их можно безопасно объединить, так как они имеют одинаковый SHA дерева. + + + Commit с дублирующимися сообщениями + + + Найдено {0} групп commit с идентичными сообщениями, но разными изменениями кода ({1} commit). Рассмотрите возможность использования более описательных сообщений для различения изменений. + + + Избыточное количество merge commit + + + Высокое соотношение merge commit + + + Ваш репозиторий имеет {0}% соотношение merge commit ({1}/{2} commit). Рассмотрите использование rebase workflow или squash merge. + + + Обнаружены commit с исправлениями merge + + + Найдено {0} commit с сообщениями типа 'fix merge', обнаруженных после merge. + + + Перекрестные merge между branch + + + Обнаружено {0} перекрестных merge между feature branch. Используйте feature branch, которые сливаются только в main. + + + Устаревшие branch + + + Найдено {0} branch без активности более 30 дней. + + + + + У вас есть незафиксированные изменения. Пожалуйста, сначала выполните commit или stash их. + + + {0} commit уже были отправлены на удаленный сервер. Их перезапись потребует force push и может повлиять на других участников. + + + Ваша branch отстает от удаленной на {0} commit. Рассмотрите возможность сначала выполнить pull, чтобы избежать конфликтов. + + + + + В репозитории есть заметные проблемы, которые следует устранить. + + + Репозиторий требует немедленного внимания. История сильно ухудшена. + + + diff --git a/Resources/Strings/LibStrings.zh.resx b/Resources/Strings/LibStrings.zh.resx new file mode 100644 index 0000000..65a074f --- /dev/null +++ b/Resources/Strings/LibStrings.zh.resx @@ -0,0 +1,421 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Commit 消息为空 + + + 主题长度为 {0} 个字符,最小长度为 {1} + + + 主题长度为 {0} 个字符,建议最大长度为 {1} + + + 主题使用了无意义的短语:'{0}' + + + 消息未遵循约定式提交格式(类型: 主题) + + + 未知的约定式提交类型:{0} + + + 未找到问题引用(例如 #123 或 JIRA-123) + + + 主题应以大写字母开头 + + + 主题不应以句号结尾 + + + 使用祈使语气:'{0}' → '{1}'(例如,使用 'Add' 而不是 'Added') + + + 正文长度为 {0} 个字符,最小长度为 {1} + + + 在主题和正文之间添加一个空行 + + + '{0}' 未描述 {1} 个文件中的更改内容 + + + 消息对于 {0} 个已更改的文件来说过于模糊 - 请描述具体更改了什么 + + + 较大的更改({0} 个文件,{1} 行)需要更详细的消息 + + + 重大更改({0} 个文件)应包含解释原因的正文 + + + 考虑提及更改的区域(文件:{0}) + + + + + 仓库中没有 commit + + + 未找到 commit:{0} + + + 目标 commit 不是 HEAD 的祖先 + + + 仓库中未找到任何目标 commit + + + Commit 创建失败:commit {0} 的父提交不匹配 + + + 无法将 HEAD 更新到新的 commit {0} + + + 磁盘验证失败:HEAD 应为 {0} 但实际为 {1} + + + 重写后旧的 commit {0} 仍可从 HEAD 访问 + + + Git 错误:{0} + + + 未找到远程仓库 '{0}' + + + 未配置上游 branch 且未找到 'origin' 远程仓库。 + +请手动设置跟踪:git push -u origin {0} + + + 强制 push 成功 + + + 已强制 push 到 origin/{0} + + + 无法启动 git 进程 + + + 强制 push 成功(通过 git 命令) + + + Push 失败:{0} + + + 无法运行 git 命令:{0} + + + 未配置上游 branch。请设置跟踪:git push -u origin <branch> + + + Push 被拒绝:非快进式。请先 pull 更改或使用强制 push。 + + + Push 成功(通过 git 命令) + + + + + 部分 commit 已被 push。启用 'AllowPushedCommits' 以继续。 + + + 清理类型 '{0}' 尚未实现 + + + 正在重建 commit 历史... + + + 正在重建 {0} 个 commit... + + + 正在处理 commit {0}/{1}... + + + 正在更新 branch 引用... + + + 正在压缩 merge commit... + + + 删除重复 commit 失败:{0} + + + 至少需要 2 个 commit 才能压缩 + + + 当前 branch 上未找到 commit + + + 未找到要删除的匹配 commit + + + 未指定要压缩的 commit + + + 未找到要压缩的匹配 merge commit + + + 压缩 merge commit 失败:{0} + + + 未指定要修复的 commit + + + 修复作者信息失败:{0} + + + 正在合并 merge 修复 commit... + + + 没有要合并的修复 commit + + + 未找到要合并的匹配修复 commit + + + 合并 merge 修复 commit 失败:{0} + + + 正在归档陈旧的 branch... + + + 正在处理 branch {0}... + + + 归档完成 + + + 归档 branch 失败:{0} + + + 正在分析 branch 结构... + + + 找到 {0} 个要线性化的 commit... + + + 正在线性化 {0} 个 commit(移除 {1} 个 merge)... + + + 正在重建 commit {0}/{1}... + + + 正在协调最终状态... + + + 历史已是线性的 - 未找到 merge commit + + + 线性化完成 + + + 线性化历史失败:{0} + + + 将重写 {0} 条 commit 消息以提高质量。 + + + 将把 {0} 个重复的 commit 压缩为 1 个。 + + + 将合并 {0} 个 merge 修复 commit。 + + + 将修复 {0} 个 commit 的作者信息。 + + + 将合并 {0} 个简单的 merge。 + + + 将归档陈旧的 branch(如已合并则删除,否则添加标签)。 + + + 将通过移除 merge commit 并按日期排序来线性化历史。 + + + 将处理 {0} 个 commit。 + + + 协调:在线性化后合并最终状态 + + + + + 需要 WorkspaceRoot + + + WorkspaceRoot 目录不存在:{0} + + + MaxCommitsPerRepo 必须大于 0 + + + Rules 不能为 null + + + Ai 选项不能为 null + + + 无效的 GitImproverOptions:{0} + + + 权重总和必须为 1.0(当前:{0}) + + + + + 未知错误 + + + 仓库未注册 + + + 存在未提交的更改,无法重写 commit。请先提交或暂存您的更改。 + + + 未找到仓库:{0} + + + 没有可用的建议消息 + + + 仓库未注册:{0} + + + 未配置 API 密钥。请在设置中设置您的 API 密钥。 + + + AI 分析失败 + + + AI 未返回结构化输出 - 已回退到原始消息 + + + Push 成功 + + + + + 正在加载 commit + + + 正在检测重复项 + + + 正在分析 merge commit + + + 正在分析 branch 复杂度 + + + 正在分析消息质量 + + + 正在分析作者信息 + + + 完成 + + + + + 内容相同的重复 commit + + + 找到 {0} 组内容相同的 commit({1} 个冗余 commit)。这些可以安全压缩,因为它们具有相同的树 SHA。 + + + 消息重复的 commit + + + 找到 {0} 组消息相同但代码更改不同的 commit({1} 个 commit)。考虑使用更具描述性的消息来区分更改。 + + + 过多的 merge commit + + + Merge commit 比例过高 + + + 您的仓库有 {0}% 的 merge commit 比例({1}/{2} 个 commit)。考虑使用 rebase 工作流或压缩 merge。 + + + 检测到 merge 修复 commit + + + 在 merge 后找到 {0} 个类似 'fix merge' 的消息的 commit。 + + + Branch 之间的交叉 merge + + + 检测到 {0} 个功能 branch 之间的交叉 merge。使用仅 merge 到主分支的功能 branch。 + + + 陈旧的 branch + + + 找到 {0} 个超过 30 天没有活动的 branch。 + + + + + 您有未提交的更改。请先提交或暂存它们。 + + + {0} 个 commit 已被 push 到远程仓库。重写它们将需要强制 push,并可能影响协作者。 + + + 您的 branch 落后远程仓库 {0} 个 commit。考虑先 pull 以避免冲突。 + + + + + 仓库存在应该解决的明显问题。 + + + 仓库需要立即关注。历史严重退化。 + + + diff --git a/Resources/Strings/Strings.cs b/Resources/Strings/Strings.cs new file mode 100644 index 0000000..4be2cfd --- /dev/null +++ b/Resources/Strings/Strings.cs @@ -0,0 +1,192 @@ +using System.Globalization; +using System.Resources; + +namespace MarketAlly.GitCommitEditor.Resources; + +/// +/// Provides localized strings for the GitCommitEditor library. +/// +public static class Str +{ + private static readonly ResourceManager ResourceManager = + new("MarketAlly.GitCommitEditor.Resources.Strings.LibStrings", typeof(Str).Assembly); + + private static CultureInfo? _culture; + + /// + /// Gets or sets the culture used for resource lookups. + /// If null, uses the current UI culture. + /// + public static CultureInfo? Culture + { + get => _culture; + set => _culture = value; + } + + /// + /// Gets a localized string by key. + /// + public static string Get(string key) + { + return ResourceManager.GetString(key, _culture) ?? key; + } + + /// + /// Gets a localized string by key with format arguments. + /// + public static string Get(string key, params object[] args) + { + var format = ResourceManager.GetString(key, _culture) ?? key; + return string.Format(format, args); + } + + // ==================== Commit Message Analyzer ==================== + + public static string Analyzer_MessageEmpty => Get("Analyzer_MessageEmpty"); + public static string Analyzer_SubjectTooShort(int length, int min) => Get("Analyzer_SubjectTooShort", length, min); + public static string Analyzer_SubjectTooLong(int length, int max) => Get("Analyzer_SubjectTooLong", length, max); + public static string Analyzer_BannedPhrase(string phrase) => Get("Analyzer_BannedPhrase", phrase); + public static string Analyzer_NotConventional => Get("Analyzer_NotConventional"); + public static string Analyzer_UnknownType(string type) => Get("Analyzer_UnknownType", type); + public static string Analyzer_NoIssueRef => Get("Analyzer_NoIssueRef"); + public static string Analyzer_CapitalLetter => Get("Analyzer_CapitalLetter"); + public static string Analyzer_NoPeriod => Get("Analyzer_NoPeriod"); + public static string Analyzer_ImperativeMood(string word, string suggestion) => Get("Analyzer_ImperativeMood", word, suggestion); + public static string Analyzer_BodyTooShort(int length, int min) => Get("Analyzer_BodyTooShort", length, min); + public static string Analyzer_BlankLine => Get("Analyzer_BlankLine"); + public static string Analyzer_NotDescriptive(string subject, int fileCount) => Get("Analyzer_NotDescriptive", subject, fileCount); + public static string Analyzer_TooVague(int fileCount) => Get("Analyzer_TooVague", fileCount); + public static string Analyzer_LargeChange(int fileCount, int lineCount) => Get("Analyzer_LargeChange", fileCount, lineCount); + public static string Analyzer_MajorChange(int fileCount) => Get("Analyzer_MajorChange", fileCount); + public static string Analyzer_MentionArea(string extensions) => Get("Analyzer_MentionArea", extensions); + + // ==================== Git Operations Service ==================== + + public static string Git_NoCommits => Get("Git_NoCommits"); + public static string Git_CommitNotFound(string hash) => Get("Git_CommitNotFound", hash); + public static string Git_NotAncestor => Get("Git_NotAncestor"); + public static string Git_NoTargetCommits => Get("Git_NoTargetCommits"); + public static string Git_ParentMismatch(string sha) => Get("Git_ParentMismatch", sha); + public static string Git_HeadUpdateFailed(string sha) => Get("Git_HeadUpdateFailed", sha); + public static string Git_VerificationFailed(string expected, string actual) => Get("Git_VerificationFailed", expected, actual); + public static string Git_OldCommitReachable(string sha) => Get("Git_OldCommitReachable", sha); + public static string Git_Error(string message) => Get("Git_Error", message); + public static string Git_RemoteNotFound(string remote) => Get("Git_RemoteNotFound", remote); + public static string Git_NoUpstreamNoOrigin(string branch) => Get("Git_NoUpstreamNoOrigin", branch); + public static string Git_ForcePushSuccess => Get("Git_ForcePushSuccess"); + public static string Git_ForcePushedTo(string branch) => Get("Git_ForcePushedTo", branch); + public static string Git_ProcessFailed => Get("Git_ProcessFailed"); + public static string Git_ForcePushSuccessCmd => Get("Git_ForcePushSuccessCmd"); + public static string Git_PushFailed(string message) => Get("Git_PushFailed", message); + public static string Git_CommandFailed(string message) => Get("Git_CommandFailed", message); + public static string Git_NoUpstream => Get("Git_NoUpstream"); + public static string Git_NonFastForward => Get("Git_NonFastForward"); + public static string Git_PushSuccessCmd => Get("Git_PushSuccessCmd"); + + // ==================== Cleanup Executor ==================== + + public static string Cleanup_PushedCommitsBlocked => Get("Cleanup_PushedCommitsBlocked"); + public static string Cleanup_NotImplemented(string type) => Get("Cleanup_NotImplemented", type); + public static string Cleanup_Rebuilding => Get("Cleanup_Rebuilding"); + public static string Cleanup_RebuildingCount(int count) => Get("Cleanup_RebuildingCount", count); + public static string Cleanup_ProcessingCommit(int current, int total) => Get("Cleanup_ProcessingCommit", current, total); + public static string Cleanup_UpdatingBranch => Get("Cleanup_UpdatingBranch"); + public static string Cleanup_SquashingMerges => Get("Cleanup_SquashingMerges"); + public static string Cleanup_DropDuplicatesFailed(string message) => Get("Cleanup_DropDuplicatesFailed", message); + public static string Cleanup_NeedTwoCommits => Get("Cleanup_NeedTwoCommits"); + public static string Cleanup_NoCommitsOnBranch => Get("Cleanup_NoCommitsOnBranch"); + public static string Cleanup_NoMatchingCommits => Get("Cleanup_NoMatchingCommits"); + public static string Cleanup_NoCommitsToSquash => Get("Cleanup_NoCommitsToSquash"); + public static string Cleanup_NoMergeCommits => Get("Cleanup_NoMergeCommits"); + public static string Cleanup_SquashMergeFailed(string message) => Get("Cleanup_SquashMergeFailed", message); + public static string Cleanup_NoCommitsToFix => Get("Cleanup_NoCommitsToFix"); + public static string Cleanup_FixAuthorFailed(string message) => Get("Cleanup_FixAuthorFailed", message); + public static string Cleanup_ConsolidatingFixes => Get("Cleanup_ConsolidatingFixes"); + public static string Cleanup_NoFixCommits => Get("Cleanup_NoFixCommits"); + public static string Cleanup_NoMatchingFixes => Get("Cleanup_NoMatchingFixes"); + public static string Cleanup_ConsolidateFailed(string message) => Get("Cleanup_ConsolidateFailed", message); + public static string Cleanup_ArchivingBranches => Get("Cleanup_ArchivingBranches"); + public static string Cleanup_ProcessingBranch(string branch) => Get("Cleanup_ProcessingBranch", branch); + public static string Cleanup_ArchiveComplete => Get("Cleanup_ArchiveComplete"); + public static string Cleanup_ArchiveFailed(string message) => Get("Cleanup_ArchiveFailed", message); + public static string Cleanup_AnalyzingStructure => Get("Cleanup_AnalyzingStructure"); + public static string Cleanup_FoundCommits(int count) => Get("Cleanup_FoundCommits", count); + public static string Cleanup_Linearizing(int commits, int merges) => Get("Cleanup_Linearizing", commits, merges); + public static string Cleanup_RebuildingCommit(int current, int total) => Get("Cleanup_RebuildingCommit", current, total); + public static string Cleanup_Reconciling => Get("Cleanup_Reconciling"); + public static string Cleanup_AlreadyLinear => Get("Cleanup_AlreadyLinear"); + public static string Cleanup_LinearizeComplete => Get("Cleanup_LinearizeComplete"); + public static string Cleanup_LinearizeFailed(string message) => Get("Cleanup_LinearizeFailed", message); + public static string Cleanup_DescReword(int count) => Get("Cleanup_DescReword", count); + public static string Cleanup_DescSquash(int count) => Get("Cleanup_DescSquash", count); + public static string Cleanup_DescSquashMerges(int count) => Get("Cleanup_DescTrivialMerges", count); + public static string Cleanup_DescConsolidate(int count) => Get("Cleanup_DescConsolidate", count); + public static string Cleanup_DescAuthorship(int count) => Get("Cleanup_DescAuthorship", count); + public static string Cleanup_DescFixAuthor(int count) => Get("Cleanup_DescAuthorship", count); + public static string Cleanup_DescTrivialMerges(int count) => Get("Cleanup_DescTrivialMerges", count); + public static string Cleanup_DescArchive => Get("Cleanup_DescArchive"); + public static string Cleanup_DescLinearize => Get("Cleanup_DescLinearize"); + public static string Cleanup_DescGeneric(int count) => Get("Cleanup_DescGeneric", count); + public static string Cleanup_DescDefault(int count) => Get("Cleanup_DescGeneric", count); + public static string Cleanup_ReconcileMerge => Get("Cleanup_ReconcileMerge"); + + // ==================== Validation ==================== + + public static string Validation_WorkspaceRequired => Get("Validation_WorkspaceRequired"); + public static string Validation_WorkspaceNotFound(string path) => Get("Validation_WorkspaceNotFound", path); + public static string Validation_MaxCommitsPositive => Get("Validation_MaxCommitsPositive"); + public static string Validation_RulesNull => Get("Validation_RulesNull"); + public static string Validation_AiOptionsNull => Get("Validation_AiOptionsNull"); + public static string Validation_InvalidOptions(string messages) => Get("Validation_InvalidOptions", messages); + public static string Validation_WeightsSum(double sum) => Get("Validation_WeightsSum", sum); + + // ==================== Service Messages ==================== + + public static string Service_UnknownError => Get("Service_UnknownError"); + public static string Service_RepoNotRegistered => Get("Service_RepoNotRegistered"); + public static string Service_UncommittedChanges => Get("Service_UncommittedChanges"); + public static string Service_RepoNotFound(string id) => Get("Service_RepoNotFound", id); + public static string Service_NoSuggestion => Get("Service_NoSuggestion"); + public static string Service_RepoNotRegisteredPath(string path) => Get("Service_RepoNotRegisteredPath", path); + public static string Service_ApiKeyNotConfigured => Get("Service_ApiKeyNotConfigured"); + public static string Service_AiAnalysisFailed => Get("Service_AiAnalysisFailed"); + public static string Service_AiFallback => Get("Service_AiFallback"); + public static string Service_PushSuccess => Get("Service_PushSuccess"); + + // ==================== Health Analyzer Status ==================== + + public static string Health_LoadingCommits => Get("Health_LoadingCommits"); + public static string Health_DetectingDuplicates => Get("Health_DetectingDuplicates"); + public static string Health_AnalyzingMerges => Get("Health_AnalyzingMerges"); + public static string Health_AnalyzingBranches => Get("Health_AnalyzingBranches"); + public static string Health_AnalyzingMessages => Get("Health_AnalyzingMessages"); + public static string Health_AnalyzingAuthorship => Get("Health_AnalyzingAuthorship"); + public static string Health_Complete => Get("Health_Complete"); + + // ==================== Health Report Issues ==================== + + public static string Report_DuplicateContent => Get("Report_DuplicateContent"); + public static string Report_DuplicateContentDesc(int groups, int redundant) => Get("Report_DuplicateContentDesc", groups, redundant); + public static string Report_DuplicateMessages => Get("Report_DuplicateMessages"); + public static string Report_DuplicateMessagesDesc(int groups, int commits) => Get("Report_DuplicateMessagesDesc", groups, commits); + public static string Report_ExcessiveMerges => Get("Report_ExcessiveMerges"); + public static string Report_HighMergeRatio => Get("Report_HighMergeRatio"); + public static string Report_MergeRatioDesc(int ratio, int merges, int total) => Get("Report_MergeRatioDesc", ratio, merges, total); + public static string Report_MergeFixCommits => Get("Report_MergeFixCommits"); + public static string Report_MergeFixDesc(int count) => Get("Report_MergeFixDesc", count); + public static string Report_CrossMerges => Get("Report_CrossMerges"); + public static string Report_CrossMergesDesc(int count) => Get("Report_CrossMergesDesc", count); + public static string Report_StaleBranches => Get("Report_StaleBranches"); + public static string Report_StaleBranchesDesc(int count) => Get("Report_StaleBranchesDesc", count); + + // ==================== Safety Warnings ==================== + + public static string Safety_UncommittedChanges => Get("Safety_UncommittedChanges"); + public static string Safety_PushedCommits(int count) => Get("Safety_PushedCommits", count); + public static string Safety_BehindRemote(int count) => Get("Safety_BehindRemote", count); + + // ==================== Health Status ==================== + + public static string HealthStatus_NeedsAttention => Get("HealthStatus_NeedsAttention"); + public static string HealthStatus_Critical => Get("HealthStatus_Critical"); +} diff --git a/Rewriters/AiCommitRewriter.cs b/Rewriters/AiCommitRewriter.cs new file mode 100755 index 0000000..c25a6a1 --- /dev/null +++ b/Rewriters/AiCommitRewriter.cs @@ -0,0 +1,199 @@ +using System.Text; +using MarketAlly.AIPlugin; +using MarketAlly.AIPlugin.Conversation; +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Options; +using MarketAlly.GitCommitEditor.Plugins; +using MarketAlly.GitCommitEditor.Services; + +namespace MarketAlly.GitCommitEditor.Rewriters; + +/// +/// Commit message rewriter using MarketAlly.AIPlugin's AIConversation API. +/// Supports multiple AI providers (Claude, OpenAI, Gemini, Mistral, Qwen). +/// +public sealed class AICommitRewriter : ICommitMessageRewriter, IDisposable +{ + private const string OperationType = "CommitSuggestion"; + private readonly AiOptions _options; + private readonly AIConversation _conversation; + private readonly ICostTrackingService? _costTracker; + + public AICommitRewriter(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.3) // Lower temperature for more consistent output + .WithMaxTokens(options.MaxTokens) + .WithToolExecutionLimit(3) + .RegisterPlugin() + .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 // Default to Claude + }; + } + + private static string BuildSystemPrompt() + { + return """ + You are a git commit message expert. Your task is to improve commit messages following conventional commit format. + + RULES: + 1. Description: max 60 chars, imperative mood ("add" not "added"), no period, lowercase start + 2. Scope is optional - use it for the area of codebase (e.g., "api", "ui", "auth") + 3. Be specific about WHAT changed based on the files and diff + 4. Add a body ONLY if the change is complex and needs explanation + 5. Body should explain WHY, not repeat WHAT + + When you receive commit information, call ReturnCommitMessage with the improved message. + """; + } + + public async Task SuggestImprovedMessageAsync(CommitAnalysis analysis, CancellationToken ct = default) + { + var prompt = BuildPrompt(analysis); + + var response = await _conversation.SendForStructuredOutputAsync( + prompt, + "ReturnCommitMessage", + ct); + + // Extract cost information from response + 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 CommitMessageResult result) + { + // Combine subject and body + var suggestion = string.IsNullOrWhiteSpace(result.Body) + ? result.Subject.Trim() + : $"{result.Subject.Trim()}\n\n{result.Body.Trim()}"; + + // Check if the suggestion is actually different from original + if (string.Equals(suggestion, analysis.OriginalMessage, StringComparison.Ordinal)) + { + return SuggestionResult.FailedWithOriginal( + analysis.OriginalMessage, + $"AI returned same message as original. Raw: {response.FinalMessage}", + inputTokens, outputTokens, cost); + } + + return SuggestionResult.Succeeded(suggestion, inputTokens, outputTokens, cost); + } + + // AI failed to return structured output + return SuggestionResult.FailedWithOriginal( + analysis.OriginalMessage, + $"No structured output. FinalMessage: {response.FinalMessage ?? "(null)"}", + inputTokens, outputTokens, cost); + } + + public async Task> SuggestBatchAsync( + IEnumerable analyses, + IProgress? progress = null, + CancellationToken ct = default) + { + var results = new List<(CommitAnalysis, SuggestionResult)>(); + var analysisList = analyses.ToList(); + var processed = 0; + + foreach (var analysis in analysisList) + { + if (ct.IsCancellationRequested) break; + + try + { + var result = await SuggestImprovedMessageAsync(analysis, ct); + results.Add((analysis, result)); + } + catch (Exception ex) + { + results.Add((analysis, SuggestionResult.Failed(ex.Message))); + } + + processed++; + progress?.Report(processed); + + // Rate limiting delay between requests + if (processed < analysisList.Count && _options.RateLimitDelayMs > 0) + { + await Task.Delay(_options.RateLimitDelayMs, ct); + } + } + + return results; + } + + private string BuildPrompt(CommitAnalysis analysis) + { + var sb = new StringBuilder(); + + sb.AppendLine("Please improve this commit message:"); + sb.AppendLine(); + sb.AppendLine($"ORIGINAL MESSAGE: {analysis.OriginalMessage}"); + sb.AppendLine(); + sb.AppendLine("FILES CHANGED:"); + + var files = analysis.FilesChanged.Take(20).ToList(); + foreach (var file in files) + { + sb.AppendLine($" - {file}"); + } + if (analysis.FilesChanged.Count > 20) + { + sb.AppendLine($" ... and {analysis.FilesChanged.Count - 20} more files"); + } + + sb.AppendLine(); + sb.AppendLine($"STATS: +{analysis.LinesAdded} -{analysis.LinesDeleted} lines"); + + if (!string.IsNullOrEmpty(analysis.DiffSummary) && _options.IncludeDiffContext) + { + sb.AppendLine(); + sb.AppendLine("DIFF SUMMARY:"); + var diffLines = analysis.DiffSummary.Split('\n').Take(_options.MaxDiffLines); + sb.AppendLine(string.Join('\n', diffLines)); + } + + if (analysis.Quality.Issues.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("ISSUES WITH CURRENT MESSAGE:"); + foreach (var issue in analysis.Quality.Issues) + { + sb.AppendLine($" - [{issue.Severity}] {issue.Message}"); + } + } + + sb.AppendLine(); + sb.AppendLine("Call ReturnCommitMessage with the improved message."); + + return sb.ToString(); + } + + public void Dispose() + { + // AIConversation handles its own disposal + } +} diff --git a/Rewriters/DynamicCommitRewriter.cs b/Rewriters/DynamicCommitRewriter.cs new file mode 100644 index 0000000..67b1bc4 --- /dev/null +++ b/Rewriters/DynamicCommitRewriter.cs @@ -0,0 +1,87 @@ +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Options; +using MarketAlly.GitCommitEditor.Resources; +using MarketAlly.GitCommitEditor.Services; + +namespace MarketAlly.GitCommitEditor.Rewriters; + +/// +/// Exception thrown when AI features are used without an API key configured. +/// +public class ApiKeyNotConfiguredException : InvalidOperationException +{ + public ApiKeyNotConfiguredException() + : base(Str.Service_ApiKeyNotConfigured) + { + } +} + +/// +/// Dynamic commit rewriter that uses AI when configured, or throws if not. +/// +public sealed class DynamicCommitRewriter : ICommitMessageRewriter, IDisposable +{ + private readonly AiOptions _options; + private readonly ICostTrackingService? _costTracker; + private AICommitRewriter? _aiRewriter; + private string? _lastApiKey; + private string? _lastProvider; + private string? _lastModel; + + public DynamicCommitRewriter(AiOptions options, ICostTrackingService? costTracker = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _costTracker = costTracker; + } + + /// + /// Returns true if an API key is configured and AI features are available. + /// + public bool IsConfigured => !string.IsNullOrEmpty(_options.ApiKey); + + private AICommitRewriter GetRewriter() + { + // If no API key, throw exception + if (string.IsNullOrEmpty(_options.ApiKey)) + { + throw new ApiKeyNotConfiguredException(); + } + + // Check if we need to create/recreate the AI rewriter + // (API key, provider, or model changed) + if (_aiRewriter == null || + _lastApiKey != _options.ApiKey || + _lastProvider != _options.Provider || + _lastModel != _options.Model) + { + // Dispose old rewriter if exists + _aiRewriter?.Dispose(); + + // Create new rewriter with current settings + _aiRewriter = new AICommitRewriter(_options, _costTracker); + _lastApiKey = _options.ApiKey; + _lastProvider = _options.Provider; + _lastModel = _options.Model; + } + + return _aiRewriter; + } + + public Task SuggestImprovedMessageAsync(CommitAnalysis analysis, CancellationToken ct = default) + { + return GetRewriter().SuggestImprovedMessageAsync(analysis, ct); + } + + public Task> SuggestBatchAsync( + IEnumerable analyses, + IProgress? progress = null, + CancellationToken ct = default) + { + return GetRewriter().SuggestBatchAsync(analyses, progress, ct); + } + + public void Dispose() + { + _aiRewriter?.Dispose(); + } +} diff --git a/Rewriters/ICommitMessageRewriter.cs b/Rewriters/ICommitMessageRewriter.cs new file mode 100644 index 0000000..b9c8c58 --- /dev/null +++ b/Rewriters/ICommitMessageRewriter.cs @@ -0,0 +1,13 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Rewriters; + +public interface ICommitMessageRewriter +{ + Task SuggestImprovedMessageAsync(CommitAnalysis analysis, CancellationToken ct = default); + + Task> SuggestBatchAsync( + IEnumerable analyses, + IProgress? progress = null, + CancellationToken ct = default); +} diff --git a/Services/CleanupExecutor.cs b/Services/CleanupExecutor.cs new file mode 100755 index 0000000..fea76ab --- /dev/null +++ b/Services/CleanupExecutor.cs @@ -0,0 +1,1319 @@ +using System.Diagnostics; +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; +using MarketAlly.GitCommitEditor.Resources; +using MarketAlly.GitCommitEditor.Rewriters; +using MarketAlly.LibGit2Sharp; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Executes cleanup operations on git repositories. +/// +public sealed class CleanupExecutor : ICleanupExecutor +{ + private readonly IGitOperationsService _gitOps; + private readonly ICommitMessageAnalyzer _analyzer; + private readonly ICommitMessageRewriter? _rewriter; + + public CleanupExecutor( + IGitOperationsService gitOps, + ICommitMessageAnalyzer analyzer, + ICommitMessageRewriter? rewriter = null) + { + _gitOps = gitOps; + _analyzer = analyzer; + _rewriter = rewriter; + } + + public async Task ExecuteAsync( + ManagedRepo repo, + CleanupOperation operation, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + options ??= new CleanupExecutionOptions(); + var stopwatch = Stopwatch.StartNew(); + string? backupBranch = null; + + try + { + // Create backup if requested + if (options.CreateBackup) + { + backupBranch = await CreateBackupBranchAsync(repo, options.BackupBranchName, ct); + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = operation.Title, + CurrentIndex = 1, + TotalOperations = 1, + PercentComplete = 10 + }); + + // Check if commits are pushed + if (!options.AllowPushedCommits && operation.AffectedCommits.Count > 0) + { + var anyPushed = operation.AffectedCommits.Any(hash => + _gitOps.IsCommitPushed(repo.Path, hash)); + + if (anyPushed) + { + return new CleanupExecutionResult + { + OperationId = operation.Id, + Type = operation.Type, + Success = false, + ErrorMessage = Str.Cleanup_PushedCommitsBlocked, + BackupBranch = backupBranch, + Duration = stopwatch.Elapsed + }; + } + } + + // Execute based on operation type + var result = operation.Type switch + { + CleanupType.RewordMessages => await ExecuteRewordAsync(repo, operation, options, progress, ct), + CleanupType.SquashDuplicates => await ExecuteSquashDuplicatesAsync(repo, operation, progress, ct), + CleanupType.SquashMerges => await ExecuteSquashMergesAsync(repo, operation, progress, ct), + CleanupType.FixAuthorship => await ExecuteFixAuthorshipAsync(repo, operation, progress, ct), + CleanupType.ConsolidateMerges => await ExecuteConsolidateMergesAsync(repo, operation, progress, ct), + CleanupType.ArchiveBranches => await ExecuteArchiveBranchesAsync(repo, operation, progress, ct), + CleanupType.RebaseLinearize => await ExecuteRebaseLinearizeAsync(repo, operation, progress, ct), + _ => new CleanupExecutionResult + { + OperationId = operation.Id, + Type = operation.Type, + Success = false, + ErrorMessage = Str.Cleanup_NotImplemented(operation.Type.ToString()), + Duration = stopwatch.Elapsed + } + }; + + stopwatch.Stop(); + + return result with + { + BackupBranch = backupBranch, + Duration = stopwatch.Elapsed, + RequiresForcePush = result.CommitsModified > 0 || result.CommitsRemoved > 0 + }; + } + catch (Exception ex) + { + stopwatch.Stop(); + return new CleanupExecutionResult + { + OperationId = operation.Id, + Type = operation.Type, + Success = false, + ErrorMessage = ex.Message, + BackupBranch = backupBranch, + Duration = stopwatch.Elapsed + }; + } + } + + public async Task ExecuteBatchAsync( + ManagedRepo repo, + IEnumerable operations, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + options ??= new CleanupExecutionOptions(); + var opList = operations.ToList(); + var results = new List(); + var stopwatch = Stopwatch.StartNew(); + string? backupBranch = null; + + // Create single backup for all operations + if (options.CreateBackup) + { + backupBranch = await CreateBackupBranchAsync(repo, options.BackupBranchName, ct); + } + + // Don't create backup for individual operations since we have one for the batch + var individualOptions = options with { CreateBackup = false }; + + for (int i = 0; i < opList.Count; i++) + { + if (ct.IsCancellationRequested) break; + + var operation = opList[i]; + progress?.Report(new CleanupProgress + { + CurrentOperation = operation.Title, + CurrentIndex = i + 1, + TotalOperations = opList.Count, + PercentComplete = (i * 100) / opList.Count + }); + + var result = await ExecuteAsync(repo, operation, individualOptions, null, ct); + results.Add(result); + + // Stop on critical failure + if (!result.Success && operation.Risk >= RiskLevel.High) + { + break; + } + } + + stopwatch.Stop(); + + return new BatchCleanupResult + { + TotalOperations = opList.Count, + Successful = results.Count(r => r.Success), + Failed = results.Count(r => !r.Success), + Skipped = opList.Count - results.Count, + BackupBranch = backupBranch, + RequiresForcePush = results.Any(r => r.RequiresForcePush), + Results = results, + TotalDuration = stopwatch.Elapsed + }; + } + + public Task PreviewAsync( + ManagedRepo repo, + CleanupOperation operation, + CancellationToken ct = default) + { + return Task.FromResult(new CleanupPreview + { + CommitsAffected = operation.AffectedCommits.Count, + RefsAffected = 1, + CommitsToModify = operation.AffectedCommits, + CommitsToRemove = operation.Type == CleanupType.SquashDuplicates + ? operation.AffectedCommits.Skip(1).ToList() + : [], + ExpectedScoreImprovement = operation.ExpectedScoreImprovement, + Summary = GeneratePreviewSummary(operation) + }); + } + + public Task CreateBackupBranchAsync( + ManagedRepo repo, + string? branchName = null, + CancellationToken ct = default) + { + branchName ??= $"backup/pre-cleanup-{DateTime.Now:yyyyMMdd-HHmmss}"; + + using var repository = new Repository(repo.Path); + var branch = repository.CreateBranch(branchName); + + return Task.FromResult(branch.FriendlyName); + } + + private async Task ExecuteRewordAsync( + ManagedRepo repo, + CleanupOperation operation, + CleanupExecutionOptions options, + IProgress? progress, + CancellationToken ct) + { + var modifiedHashes = new List(); + var newHashes = new List(); + + foreach (var commitHash in operation.AffectedCommits) + { + ct.ThrowIfCancellationRequested(); + + try + { + // Get the commit + using var repository = new Repository(repo.Path); + var commit = repository.Lookup(commitHash); + if (commit == null) continue; + + // Analyze current message + var analysis = _analyzer.Analyze(commit.Message); + if (!analysis.NeedsImprovement) continue; + + string newMessage; + + // Generate improved message + if (options.UseAiForMessages && _rewriter != null) + { + var commitAnalysis = new CommitAnalysis + { + RepoId = repo.Id, + RepoName = repo.Name, + RepoPath = repo.Path, + CommitHash = commitHash, + OriginalMessage = commit.Message, + Author = commit.Author.Name, + AuthorEmail = commit.Author.Email, + CommitDate = commit.Author.When, + Quality = analysis, + IsLatestCommit = repository.Head.Tip.Sha == commitHash + }; + + var suggestionResult = await _rewriter.SuggestImprovedMessageAsync(commitAnalysis, ct); + if (suggestionResult.Success && !string.IsNullOrEmpty(suggestionResult.Suggestion)) + { + newMessage = suggestionResult.Suggestion; + } + else + { + // AI failed, fall back to basic cleanup + newMessage = CleanupMessage(commit.Message); + } + } + else + { + // Basic cleanup without AI + newMessage = CleanupMessage(commit.Message); + } + + // Apply the reword + var isLatest = repository.Head.Tip.Sha == commitHash; + var result = isLatest + ? _gitOps.AmendLatestCommit(repo, newMessage) + : _gitOps.RewordOlderCommit(repo, commitHash, newMessage); + + if (result.Status == OperationStatus.Applied) + { + modifiedHashes.Add(commitHash); + if (!string.IsNullOrEmpty(result.NewCommitHash)) + { + newHashes.Add(result.NewCommitHash); + } + } + } + catch + { + // Continue with next commit + } + } + + return new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.RewordMessages, + Success = modifiedHashes.Count > 0, + CommitsModified = modifiedHashes.Count, + ModifiedCommitHashes = modifiedHashes, + NewCommitHashes = newHashes + }; + } + + private Task ExecuteSquashDuplicatesAsync( + ManagedRepo repo, + CleanupOperation operation, + IProgress? progress, + CancellationToken ct) + { + if (operation.AffectedCommits.Count < 2) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashDuplicates, + Success = false, + ErrorMessage = Str.Cleanup_NeedTwoCommits + }); + } + + try + { + // The first commit in AffectedCommits is kept, the rest are dropped + var hashesToDrop = operation.AffectedCommits.Skip(1).ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (hashesToDrop.Count == 0) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashDuplicates, + Success = true, + CommitsRemoved = 0 + }); + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_Rebuilding, + PercentComplete = 10 + }); + + using var repository = new Repository(repo.Path); + + // Get the current branch + var currentBranch = repository.Head; + if (currentBranch.Tip == null) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashDuplicates, + Success = false, + ErrorMessage = Str.Cleanup_NoCommitsOnBranch + }); + } + + // Collect all commits from HEAD to root in reverse order (oldest first) + var allCommits = new List(); + var commit = currentBranch.Tip; + while (commit != null) + { + allCommits.Add(commit); + commit = commit.Parents.FirstOrDefault(); + } + allCommits.Reverse(); // Now oldest is first + + // Filter out the commits we want to drop + var commitsToKeep = allCommits + .Where(c => !hashesToDrop.Contains(c.Sha)) + .ToList(); + + if (commitsToKeep.Count == allCommits.Count) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashDuplicates, + Success = true, + CommitsRemoved = 0, + ErrorMessage = Str.Cleanup_NoMatchingCommits + }); + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_RebuildingCount(commitsToKeep.Count), + PercentComplete = 30 + }); + + // Rebuild the commit chain + Commit? newParent = null; + var newCommits = new List(); + + for (int i = 0; i < commitsToKeep.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var originalCommit = commitsToKeep[i]; + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, commitsToKeep.Count), + PercentComplete = 30 + (i * 60 / commitsToKeep.Count) + }); + + // Create the new commit with the same content but new parent chain + var parents = newParent != null + ? new[] { newParent } + : Array.Empty(); + + var newCommit = repository.ObjectDatabase.CreateCommit( + originalCommit.Author, + originalCommit.Committer, + originalCommit.Message, + originalCommit.Tree, + parents, + prettifyMessage: false); + + newCommits.Add(newCommit); + newParent = newCommit; + } + + // Update the branch to point to the new tip + if (newParent != null) + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_UpdatingBranch, + PercentComplete = 95 + }); + + // Get the branch name before any updates + var branchName = currentBranch.FriendlyName; + var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; + + if (!isDetached && !string.IsNullOrEmpty(branchName)) + { + // We're on a branch - update the branch ref and keep HEAD attached + var branchRef = repository.Refs[$"refs/heads/{branchName}"]; + if (branchRef != null) + { + // Update the branch to point to new commit + repository.Refs.UpdateTarget(branchRef, newParent.Id, + $"cleanup: dropped {hashesToDrop.Count} duplicate commits"); + + // Checkout the branch to update HEAD and working directory + Commands.Checkout(repository, repository.Branches[branchName], + new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); + } + } + else + { + // Detached HEAD - just reset + repository.Reset(ResetMode.Hard, newParent); + } + } + + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashDuplicates, + Success = true, + CommitsRemoved = hashesToDrop.Count, + CommitsModified = commitsToKeep.Count, + NewCommitHashes = newCommits.Select(c => c.Sha).ToList() + }); + } + catch (Exception ex) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashDuplicates, + Success = false, + ErrorMessage = Str.Cleanup_DropDuplicatesFailed(ex.Message) + }); + } + } + + private Task ExecuteSquashMergesAsync( + ManagedRepo repo, + CleanupOperation operation, + IProgress? progress, + CancellationToken ct) + { + // Squash merge commits by dropping them and keeping the changes + if (operation.AffectedCommits.Count == 0) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashMerges, + Success = false, + ErrorMessage = Str.Cleanup_NoCommitsToSquash + }); + } + + try + { + var hashesToDrop = operation.AffectedCommits.ToHashSet(StringComparer.OrdinalIgnoreCase); + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_SquashingMerges, + PercentComplete = 10 + }); + + using var repository = new Repository(repo.Path); + + var currentBranch = repository.Head; + if (currentBranch.Tip == null) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashMerges, + Success = false, + ErrorMessage = Str.Cleanup_NoCommitsOnBranch + }); + } + + // Collect all commits from HEAD to root (oldest first) + var allCommits = new List(); + var commit = currentBranch.Tip; + while (commit != null) + { + allCommits.Add(commit); + commit = commit.Parents.FirstOrDefault(); + } + allCommits.Reverse(); + + // Filter out the merge commits we want to drop + var commitsToKeep = allCommits + .Where(c => !hashesToDrop.Contains(c.Sha)) + .ToList(); + + if (commitsToKeep.Count == allCommits.Count) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashMerges, + Success = true, + CommitsRemoved = 0, + ErrorMessage = Str.Cleanup_NoMergeCommits + }); + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_RebuildingCount(commitsToKeep.Count), + PercentComplete = 30 + }); + + // Rebuild the commit chain without the merge commits + Commit? newParent = null; + var newCommits = new List(); + + for (int i = 0; i < commitsToKeep.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var originalCommit = commitsToKeep[i]; + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, commitsToKeep.Count), + PercentComplete = 30 + (i * 60 / commitsToKeep.Count) + }); + + var parents = newParent != null + ? new[] { newParent } + : Array.Empty(); + + var newCommit = repository.ObjectDatabase.CreateCommit( + originalCommit.Author, + originalCommit.Committer, + originalCommit.Message, + originalCommit.Tree, + parents, + prettifyMessage: false); + + newCommits.Add(newCommit); + newParent = newCommit; + } + + // Update the branch to point to the new tip + if (newParent != null) + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_UpdatingBranch, + PercentComplete = 95 + }); + + var branchName = currentBranch.FriendlyName; + var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; + + if (!isDetached && !string.IsNullOrEmpty(branchName)) + { + var branchRef = repository.Refs[$"refs/heads/{branchName}"]; + if (branchRef != null) + { + repository.Refs.UpdateTarget(branchRef, newParent.Id, + $"cleanup: squashed {hashesToDrop.Count} merge commits"); + + Commands.Checkout(repository, repository.Branches[branchName], + new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); + } + } + else + { + repository.Reset(ResetMode.Hard, newParent); + } + } + + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashMerges, + Success = true, + CommitsRemoved = hashesToDrop.Count, + CommitsModified = commitsToKeep.Count, + NewCommitHashes = newCommits.Select(c => c.Sha).ToList() + }); + } + catch (Exception ex) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.SquashMerges, + Success = false, + ErrorMessage = Str.Cleanup_SquashMergeFailed(ex.Message) + }); + } + } + + private Task ExecuteFixAuthorshipAsync( + ManagedRepo repo, + CleanupOperation operation, + IProgress? progress, + CancellationToken ct) + { + // Fix authorship by rewriting commits with corrected author info + if (operation.AffectedCommits.Count == 0) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.FixAuthorship, + Success = false, + ErrorMessage = Str.Cleanup_NoCommitsToFix + }); + } + + try + { + var hashesToFix = operation.AffectedCommits.ToHashSet(StringComparer.OrdinalIgnoreCase); + + progress?.Report(new CleanupProgress + { + CurrentOperation = "Fixing commit authorship...", + PercentComplete = 10 + }); + + using var repository = new Repository(repo.Path); + + var currentBranch = repository.Head; + if (currentBranch.Tip == null) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.FixAuthorship, + Success = false, + ErrorMessage = Str.Cleanup_NoCommitsOnBranch + }); + } + + // Get the configured user for authorship fixes + var config = repository.Config; + var userName = config.Get("user.name")?.Value ?? "Unknown"; + var userEmail = config.Get("user.email")?.Value ?? "unknown@unknown.com"; + var newSignature = new Signature(userName, userEmail, DateTimeOffset.Now); + + // Collect all commits from HEAD to root (oldest first) + var allCommits = new List(); + var commit = currentBranch.Tip; + while (commit != null) + { + allCommits.Add(commit); + commit = commit.Parents.FirstOrDefault(); + } + allCommits.Reverse(); + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_RebuildingCount(allCommits.Count), + PercentComplete = 30 + }); + + // Rebuild the commit chain with fixed authorship + Commit? newParent = null; + var newCommits = new List(); + var modifiedCount = 0; + + for (int i = 0; i < allCommits.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var originalCommit = allCommits[i]; + var needsFix = hashesToFix.Contains(originalCommit.Sha); + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, allCommits.Count), + PercentComplete = 30 + (i * 60 / allCommits.Count) + }); + + var parents = newParent != null + ? new[] { newParent } + : Array.Empty(); + + // Use fixed signature or keep original + var author = needsFix + ? new Signature(userName, userEmail, originalCommit.Author.When) + : originalCommit.Author; + + var committer = needsFix + ? new Signature(userName, userEmail, originalCommit.Committer.When) + : originalCommit.Committer; + + var newCommit = repository.ObjectDatabase.CreateCommit( + author, + committer, + originalCommit.Message, + originalCommit.Tree, + parents, + prettifyMessage: false); + + newCommits.Add(newCommit); + newParent = newCommit; + + if (needsFix) modifiedCount++; + } + + // Update the branch to point to the new tip + if (newParent != null) + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_UpdatingBranch, + PercentComplete = 95 + }); + + var branchName = currentBranch.FriendlyName; + var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; + + if (!isDetached && !string.IsNullOrEmpty(branchName)) + { + var branchRef = repository.Refs[$"refs/heads/{branchName}"]; + if (branchRef != null) + { + repository.Refs.UpdateTarget(branchRef, newParent.Id, + $"cleanup: fixed authorship on {modifiedCount} commits"); + + Commands.Checkout(repository, repository.Branches[branchName], + new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); + } + } + else + { + repository.Reset(ResetMode.Hard, newParent); + } + } + + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.FixAuthorship, + Success = true, + CommitsModified = modifiedCount, + NewCommitHashes = newCommits.Select(c => c.Sha).ToList() + }); + } + catch (Exception ex) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.FixAuthorship, + Success = false, + ErrorMessage = Str.Cleanup_FixAuthorFailed(ex.Message) + }); + } + } + + private Task ExecuteConsolidateMergesAsync( + ManagedRepo repo, + CleanupOperation operation, + IProgress? progress, + CancellationToken ct) + { + // Consolidate merge fix commits by dropping them from history + // The AffectedCommits list contains the fix commits to be removed + + if (operation.AffectedCommits.Count == 0) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ConsolidateMerges, + Success = true, + CommitsRemoved = 0, + ErrorMessage = Str.Cleanup_NoFixCommits + }); + } + + try + { + // All commits in AffectedCommits are fix commits to be dropped + var hashesToDrop = operation.AffectedCommits.ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (hashesToDrop.Count == 0) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ConsolidateMerges, + Success = true, + CommitsRemoved = 0 + }); + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ConsolidatingFixes, + PercentComplete = 10 + }); + + using var repository = new Repository(repo.Path); + + var currentBranch = repository.Head; + if (currentBranch.Tip == null) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ConsolidateMerges, + Success = false, + ErrorMessage = Str.Cleanup_NoCommitsOnBranch + }); + } + + // Collect all commits from HEAD to root (oldest first) + var allCommits = new List(); + var commit = currentBranch.Tip; + while (commit != null) + { + allCommits.Add(commit); + commit = commit.Parents.FirstOrDefault(); + } + allCommits.Reverse(); + + // Filter out the fix commits we want to drop + var commitsToKeep = allCommits + .Where(c => !hashesToDrop.Contains(c.Sha)) + .ToList(); + + if (commitsToKeep.Count == allCommits.Count) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ConsolidateMerges, + Success = true, + CommitsRemoved = 0, + ErrorMessage = Str.Cleanup_NoMatchingFixes + }); + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_RebuildingCount(commitsToKeep.Count), + PercentComplete = 30 + }); + + // Rebuild the commit chain without the fix commits + Commit? newParent = null; + var newCommits = new List(); + + for (int i = 0; i < commitsToKeep.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var originalCommit = commitsToKeep[i]; + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ProcessingCommit(i + 1, commitsToKeep.Count), + PercentComplete = 30 + (i * 60 / commitsToKeep.Count) + }); + + var parents = newParent != null + ? new[] { newParent } + : Array.Empty(); + + var newCommit = repository.ObjectDatabase.CreateCommit( + originalCommit.Author, + originalCommit.Committer, + originalCommit.Message, + originalCommit.Tree, + parents, + prettifyMessage: false); + + newCommits.Add(newCommit); + newParent = newCommit; + } + + // Update the branch to point to the new tip + if (newParent != null) + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_UpdatingBranch, + PercentComplete = 95 + }); + + var branchName = currentBranch.FriendlyName; + var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; + + if (!isDetached && !string.IsNullOrEmpty(branchName)) + { + var branchRef = repository.Refs[$"refs/heads/{branchName}"]; + if (branchRef != null) + { + repository.Refs.UpdateTarget(branchRef, newParent.Id, + $"cleanup: consolidated {hashesToDrop.Count} merge fix commits"); + + Commands.Checkout(repository, repository.Branches[branchName], + new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); + } + } + else + { + repository.Reset(ResetMode.Hard, newParent); + } + } + + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ConsolidateMerges, + Success = true, + CommitsRemoved = hashesToDrop.Count, + CommitsModified = commitsToKeep.Count, + NewCommitHashes = newCommits.Select(c => c.Sha).ToList() + }); + } + catch (Exception ex) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ConsolidateMerges, + Success = false, + ErrorMessage = Str.Cleanup_ConsolidateFailed(ex.Message) + }); + } + } + + private Task ExecuteArchiveBranchesAsync( + ManagedRepo repo, + CleanupOperation operation, + IProgress? progress, + CancellationToken ct) + { + // Archive stale branches by deleting them (only if merged) or creating archive tags + try + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ArchivingBranches, + PercentComplete = 10 + }); + + using var repository = new Repository(repo.Path); + + var currentBranch = repository.Head.FriendlyName; + var deletedCount = 0; + var archivedBranches = new List(); + + // Get all local branches that are not the current branch + var staleBranches = repository.Branches + .Where(b => !b.IsRemote && b.FriendlyName != currentBranch) + .ToList(); + + for (int i = 0; i < staleBranches.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var branch = staleBranches[i]; + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ProcessingBranch(branch.FriendlyName), + PercentComplete = 10 + (i * 80 / Math.Max(1, staleBranches.Count)) + }); + + // Check if branch is merged into current branch + var isMerged = repository.Commits + .QueryBy(new CommitFilter { IncludeReachableFrom = repository.Head.Tip }) + .Any(c => c.Sha == branch.Tip?.Sha); + + if (isMerged) + { + // Safe to delete - it's merged + repository.Branches.Remove(branch); + deletedCount++; + archivedBranches.Add(branch.FriendlyName); + } + else + { + // Create an archive tag before deleting + var tagName = $"archive/{branch.FriendlyName}"; + if (branch.Tip != null && repository.Tags[tagName] == null) + { + repository.ApplyTag(tagName, branch.Tip.Sha, + new Signature("GitCleaner", "gitcleaner@local", DateTimeOffset.Now), + $"Archived branch: {branch.FriendlyName}"); + } + repository.Branches.Remove(branch); + deletedCount++; + archivedBranches.Add($"{branch.FriendlyName} (tagged as {tagName})"); + } + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_ArchiveComplete, + PercentComplete = 100 + }); + + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ArchiveBranches, + Success = true, + CommitsModified = 0, + CommitsRemoved = deletedCount, + NewCommitHashes = archivedBranches + }); + } + catch (Exception ex) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.ArchiveBranches, + Success = false, + ErrorMessage = Str.Cleanup_ArchiveFailed(ex.Message) + }); + } + } + + private Task ExecuteRebaseLinearizeAsync( + ManagedRepo repo, + CleanupOperation operation, + IProgress? progress, + CancellationToken ct) + { + // Linearize history by removing merge commits and creating a single-parent chain + // Strategy: Keep all non-merge commits, sorted by author date, rebuilt with linear parents + try + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_AnalyzingStructure, + PercentComplete = 5 + }); + + using var repository = new Repository(repo.Path); + + var currentBranch = repository.Head; + if (currentBranch.Tip == null) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.RebaseLinearize, + Success = false, + ErrorMessage = Str.Cleanup_NoCommitsOnBranch + }); + } + + // Collect ALL reachable commits (including from merges) + var allCommits = new HashSet(); + var queue = new Queue(); + queue.Enqueue(currentBranch.Tip); + + while (queue.Count > 0) + { + var commit = queue.Dequeue(); + if (allCommits.Add(commit)) + { + foreach (var parent in commit.Parents) + { + queue.Enqueue(parent); + } + } + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_FoundCommits(allCommits.Count), + PercentComplete = 15 + }); + + // Filter out merge commits and sort by author date (oldest first) + var nonMergeCommits = allCommits + .Where(c => c.Parents.Count() <= 1) // Keep only non-merge commits + .OrderBy(c => c.Author.When) + .ToList(); + + var mergeCount = allCommits.Count - nonMergeCommits.Count; + + if (mergeCount == 0) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.RebaseLinearize, + Success = true, + CommitsModified = 0, + ErrorMessage = Str.Cleanup_AlreadyLinear + }); + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_Linearizing(nonMergeCommits.Count, mergeCount), + PercentComplete = 20 + }); + + // Rebuild commit chain in linear order + Commit? newParent = null; + var newCommits = new List(); + Tree? lastTree = null; + + for (int i = 0; i < nonMergeCommits.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var originalCommit = nonMergeCommits[i]; + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_RebuildingCommit(i + 1, nonMergeCommits.Count), + PercentComplete = 20 + (i * 70 / nonMergeCommits.Count) + }); + + // Check for potential conflicts by comparing trees + // If this commit's tree has the same blob for a path that lastTree modified, + // we might have a conflict. For safety, we use the commit's original tree. + var parents = newParent != null + ? new[] { newParent } + : Array.Empty(); + + // Create the commit with its original tree (preserves the file state at that point) + var newCommit = repository.ObjectDatabase.CreateCommit( + originalCommit.Author, + originalCommit.Committer, + originalCommit.Message, + originalCommit.Tree, + parents, + prettifyMessage: false); + + newCommits.Add(newCommit); + newParent = newCommit; + lastTree = originalCommit.Tree; + } + + // The final tree should match HEAD's tree for consistency + // If it doesn't, create one final commit that brings us to HEAD's state + if (newParent != null && currentBranch.Tip.Tree.Id != newParent.Tree.Id) + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_Reconciling, + PercentComplete = 92 + }); + + var finalCommit = repository.ObjectDatabase.CreateCommit( + currentBranch.Tip.Author, + currentBranch.Tip.Committer, + Str.Cleanup_ReconcileMerge, + currentBranch.Tip.Tree, + new[] { newParent }, + prettifyMessage: false); + + newCommits.Add(finalCommit); + newParent = finalCommit; + } + + // Update the branch to point to the new tip + if (newParent != null) + { + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_UpdatingBranch, + PercentComplete = 95 + }); + + var branchName = currentBranch.FriendlyName; + var isDetached = currentBranch.IsCurrentRepositoryHead && repository.Info.IsHeadDetached; + + if (!isDetached && !string.IsNullOrEmpty(branchName)) + { + var branchRef = repository.Refs[$"refs/heads/{branchName}"]; + if (branchRef != null) + { + repository.Refs.UpdateTarget(branchRef, newParent.Id, + $"cleanup: linearized history, removed {mergeCount} merge commits"); + + Commands.Checkout(repository, repository.Branches[branchName], + new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force }); + } + } + else + { + repository.Reset(ResetMode.Hard, newParent); + } + } + + progress?.Report(new CleanupProgress + { + CurrentOperation = Str.Cleanup_LinearizeComplete, + PercentComplete = 100 + }); + + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.RebaseLinearize, + Success = true, + CommitsRemoved = mergeCount, + CommitsModified = nonMergeCommits.Count, + NewCommitHashes = newCommits.Select(c => c.Sha).ToList() + }); + } + catch (Exception ex) + { + return Task.FromResult(new CleanupExecutionResult + { + OperationId = operation.Id, + Type = CleanupType.RebaseLinearize, + Success = false, + ErrorMessage = Str.Cleanup_LinearizeFailed(ex.Message) + }); + } + } + + private static string CleanupMessage(string message) + { + var lines = message.Split('\n'); + if (lines.Length == 0) return message; + + var subject = lines[0].Trim(); + + // Capitalize first letter + if (subject.Length > 0 && char.IsLower(subject[0])) + { + subject = char.ToUpper(subject[0]) + subject[1..]; + } + + // Remove trailing period + if (subject.EndsWith('.')) + { + subject = subject[..^1]; + } + + // Ensure reasonable length + if (subject.Length > 72) + { + subject = subject[..69] + "..."; + } + + if (lines.Length > 1) + { + return subject + "\n" + string.Join("\n", lines.Skip(1)); + } + + return subject; + } + + private static string GeneratePreviewSummary(CleanupOperation operation) + { + return operation.Type switch + { + CleanupType.RewordMessages => + Str.Cleanup_DescReword(operation.AffectedCommits.Count), + CleanupType.SquashDuplicates => + Str.Cleanup_DescSquash(operation.AffectedCommits.Count), + CleanupType.SquashMerges => + Str.Cleanup_DescSquashMerges(operation.AffectedCommits.Count), + CleanupType.FixAuthorship => + Str.Cleanup_DescFixAuthor(operation.AffectedCommits.Count), + CleanupType.ConsolidateMerges => + Str.Cleanup_DescConsolidate(operation.AffectedCommits.Count), + CleanupType.ArchiveBranches => + Str.Cleanup_DescArchive, + CleanupType.RebaseLinearize => + Str.Cleanup_DescLinearize, + _ => Str.Cleanup_DescDefault(operation.AffectedCommits.Count) + }; + } +} diff --git a/Services/CommitMessageAnalyzer.cs b/Services/CommitMessageAnalyzer.cs new file mode 100755 index 0000000..9b10800 --- /dev/null +++ b/Services/CommitMessageAnalyzer.cs @@ -0,0 +1,337 @@ +using System.Text.RegularExpressions; +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Options; +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Services; + +public sealed partial class CommitMessageAnalyzer : ICommitMessageAnalyzer +{ + private readonly CommitMessageRules _rules; + + // Vague/lazy words that don't describe what actually changed + private static readonly string[] VagueWords = + [ + "updates", "update", "changes", "change", "stuff", "things", + "misc", "various", "some", "minor", "edits", "tweaks", "wip", + "work in progress", "modifications", "adjustments" + ]; + + // Pattern: "ProjectName updates" or "ProjectName changes" - lazy naming + [GeneratedRegex(@"^\w+\s+(updates?|changes?|edits?|tweaks?|modifications?)$", RegexOptions.IgnoreCase)] + private static partial Regex LazyProjectNamePattern(); + + [GeneratedRegex(@"^(?\w+)(?:\((?[^)]+)\))?!?:\s*(?.+)$", RegexOptions.Singleline)] + private static partial Regex ConventionalCommitPattern(); + + [GeneratedRegex(@"#\d+|[A-Z]{2,}-\d+", RegexOptions.IgnoreCase)] + private static partial Regex IssueReferencePattern(); + + [GeneratedRegex(@"\w+\([^)]+\):\s+.+")] + private static partial Regex ScopePattern(); + + public CommitMessageAnalyzer(CommitMessageRules rules) + { + _rules = rules ?? throw new ArgumentNullException(nameof(rules)); + } + + /// + /// Analyze without context - uses default context with 0 files. + /// + public MessageQualityScore Analyze(string message) => Analyze(message, new CommitContext()); + + /// + /// Analyze with full context about the changes for smarter detection. + /// + public MessageQualityScore Analyze(string message, CommitContext context) + { + if (string.IsNullOrWhiteSpace(message)) + { + return new MessageQualityScore + { + OverallScore = 0, + Issues = + [ + new QualityIssue + { + Severity = IssueSeverity.Error, + Code = "EMPTY_MESSAGE", + Message = Str.Analyzer_MessageEmpty + } + ] + }; + } + + var issues = new List(); + var lines = message.Split('\n', StringSplitOptions.None); + var subject = lines[0].Trim(); + var body = lines.Length > 2 ? string.Join('\n', lines.Skip(2)).Trim() : string.Empty; + + int score = 100; + + // Subject length checks + if (subject.Length < _rules.MinSubjectLength) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Error, + Code = "SUBJECT_TOO_SHORT", + Message = Str.Analyzer_SubjectTooShort(subject.Length, _rules.MinSubjectLength) + }); + score -= 25; + } + + if (subject.Length > _rules.MaxSubjectLength) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Warning, + Code = "SUBJECT_TOO_LONG", + Message = Str.Analyzer_SubjectTooLong(subject.Length, _rules.MaxSubjectLength) + }); + score -= 10; + } + + // Banned phrases check + // For conventional commits (type: subject), check the subject part after the colon + // For non-conventional commits, check the whole subject + var lowerSubject = subject.ToLowerInvariant(); + var conventionalMatch = ConventionalCommitPattern().Match(subject); + var textToCheck = conventionalMatch.Success + ? conventionalMatch.Groups["subject"].Value.ToLowerInvariant().Trim() + : lowerSubject; + + foreach (var banned in _rules.BannedPhrases) + { + var bannedLower = banned.ToLowerInvariant(); + if (textToCheck == bannedLower || + textToCheck.StartsWith(bannedLower + " ") || + textToCheck.StartsWith(bannedLower + ":") || + textToCheck.StartsWith(bannedLower + ".")) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Error, + Code = "BANNED_PHRASE", + Message = Str.Analyzer_BannedPhrase(banned) + }); + score -= 30; + break; + } + } + + // Conventional commit check (conventionalMatch already computed above) + if (_rules.RequireConventionalCommit) + { + if (!conventionalMatch.Success) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Error, + Code = "NOT_CONVENTIONAL", + Message = Str.Analyzer_NotConventional + }); + score -= 20; + } + else if (!_rules.ConventionalTypes.Contains(conventionalMatch.Groups["type"].Value.ToLower())) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Warning, + Code = "UNKNOWN_TYPE", + Message = Str.Analyzer_UnknownType(conventionalMatch.Groups["type"].Value) + }); + score -= 10; + } + } + + // Issue reference check + if (_rules.RequireIssueReference) + { + if (!IssueReferencePattern().IsMatch(message)) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Warning, + Code = "NO_ISSUE_REF", + Message = Str.Analyzer_NoIssueRef + }); + score -= 10; + } + } + + // Capitalization (skip if conventional commit) + if (subject.Length > 0 && !conventionalMatch.Success) + { + if (char.IsLower(subject[0])) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Info, + Code = "LOWERCASE_START", + Message = Str.Analyzer_CapitalLetter + }); + score -= 5; + } + } + + // Trailing period + if (subject.EndsWith('.')) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Info, + Code = "TRAILING_PERIOD", + Message = Str.Analyzer_NoPeriod + }); + score -= 5; + } + + // Imperative mood check (simple heuristic) + var pastTenseIndicators = new[] { "added", "fixed", "updated", "changed", "removed", "deleted", "created" }; + var firstWord = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.ToLower() ?? ""; + if (pastTenseIndicators.Contains(firstWord)) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Info, + Code = "NOT_IMPERATIVE", + Message = Str.Analyzer_ImperativeMood(firstWord, firstWord.TrimEnd('d', 'e')) + }); + score -= 5; + } + + // Body length check + if (_rules.MinBodyLength > 0 && body.Length < _rules.MinBodyLength) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Warning, + Code = "BODY_TOO_SHORT", + Message = Str.Analyzer_BodyTooShort(body.Length, _rules.MinBodyLength) + }); + score -= 10; + } + + // Blank line between subject and body + if (lines.Length > 1 && !string.IsNullOrWhiteSpace(lines[1])) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Info, + Code = "NO_BLANK_LINE", + Message = Str.Analyzer_BlankLine + }); + score -= 5; + } + + // === CONTEXT-AWARE CHECKS === + // These checks consider the actual scope of changes + + if (context.FilesChanged > 0) + { + var totalLinesChanged = context.LinesAdded + context.LinesDeleted; + + // Check for lazy "ProjectName updates" pattern + if (LazyProjectNamePattern().IsMatch(subject)) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Error, + Code = "LAZY_MESSAGE", + Message = Str.Analyzer_NotDescriptive(subject, context.FilesChanged) + }); + score -= 35; + } + + // Check for vague words with significant changes + var subjectWords = subject.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var vagueWordCount = subjectWords.Count(w => VagueWords.Contains(w)); + var hasOnlyVagueContent = vagueWordCount > 0 && subjectWords.Length <= 3; + + if (hasOnlyVagueContent && context.FilesChanged >= 3) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Error, + Code = "VAGUE_FOR_SCOPE", + Message = Str.Analyzer_TooVague(context.FilesChanged) + }); + score -= 30; + } + + // Large changes need descriptive messages + if (context.FilesChanged >= 10 && subject.Length < 30) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Warning, + Code = "LARGE_CHANGE_SHORT_MSG", + Message = Str.Analyzer_LargeChange(context.FilesChanged, totalLinesChanged) + }); + score -= 15; + } + + // Very large changes should have a body explaining context + if (context.FilesChanged >= 20 && body.Length < 20) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Warning, + Code = "LARGE_CHANGE_NO_BODY", + Message = Str.Analyzer_MajorChange(context.FilesChanged) + }); + score -= 10; + } + + // Message doesn't mention any file types or areas changed + // Skip if message already has a scope pattern like feat(api): description + if (context.FilesChanged >= 5 && context.FileNames.Count > 0) + { + // Check: word(scope): description AND body has content + var hasScope = ScopePattern().IsMatch(subject); + var bodyWords = body.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + var hasBody = bodyWords >= 3; + + // Only check for scope hint if no proper scope+body present + if (!(hasScope && hasBody)) + { + var extensions = context.FileNames + .Select(f => Path.GetExtension(f).TrimStart('.').ToLower()) + .Where(e => !string.IsNullOrEmpty(e)) + .Distinct() + .ToList(); + + var folders = context.FileNames + .Select(f => Path.GetDirectoryName(f)?.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).LastOrDefault()) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct() + .Take(5) + .ToList(); + + // Check if message mentions any relevant context + var messageLower = message.ToLowerInvariant(); + var mentionsFileType = extensions.Any(e => messageLower.Contains(e)); + var mentionsFolder = folders.Any(f => messageLower.Contains(f!.ToLowerInvariant())); + + if (!mentionsFileType && !mentionsFolder && extensions.Count > 1) + { + issues.Add(new QualityIssue + { + Severity = IssueSeverity.Info, + Code = "NO_SCOPE_HINT", + Message = Str.Analyzer_MentionArea(string.Join(", ", extensions.Take(3))) + }); + score -= 5; + } + } + } + } + + return new MessageQualityScore + { + OverallScore = Math.Max(0, score), + Issues = issues + }; + } +} diff --git a/Services/CostTrackingService.cs b/Services/CostTrackingService.cs new file mode 100644 index 0000000..9693e5c --- /dev/null +++ b/Services/CostTrackingService.cs @@ -0,0 +1,191 @@ +using System.Collections.Concurrent; +using System.Text.Json; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Persistence provider interface for cost tracking +/// +public interface ICostPersistenceProvider +{ + string? GetValue(string key); + void SetValue(string key, string value); + void Remove(string key); +} + +/// +/// Tracks AI operation costs for the session with persistence support +/// +public class CostTrackingService : ICostTrackingService +{ + private const string KeyLifetimeCost = "cost_lifetime_total"; + private const string KeyLifetimeOperations = "cost_lifetime_operations"; + private const string KeyLifetimeInputTokens = "cost_lifetime_input_tokens"; + private const string KeyLifetimeOutputTokens = "cost_lifetime_output_tokens"; + + private readonly ConcurrentDictionary _costBreakdown = new(); + private readonly ICostPersistenceProvider? _persistence; + private readonly object _costLock = new(); + + private decimal _sessionCost; + private int _totalInputTokens; + private int _totalOutputTokens; + private int _operationCount; + + private decimal _lifetimeCost; + private int _lifetimeInputTokens; + private int _lifetimeOutputTokens; + private int _lifetimeOperationCount; + + public decimal SessionCost => _sessionCost; + public decimal LifetimeCost => _lifetimeCost; + public int TotalInputTokens => _totalInputTokens; + public int TotalOutputTokens => _totalOutputTokens; + public int OperationCount => _operationCount; + public int LifetimeOperationCount => _lifetimeOperationCount; + + public event EventHandler? CostUpdated; + + public CostTrackingService(ICostPersistenceProvider? persistence = null) + { + _persistence = persistence; + LoadState(); + } + + public void RecordOperation(string operationType, int inputTokens, int outputTokens, decimal cost) + { + // Update session totals + Interlocked.Add(ref _totalInputTokens, inputTokens); + Interlocked.Add(ref _totalOutputTokens, outputTokens); + Interlocked.Increment(ref _operationCount); + + // Update lifetime totals + Interlocked.Add(ref _lifetimeInputTokens, inputTokens); + Interlocked.Add(ref _lifetimeOutputTokens, outputTokens); + Interlocked.Increment(ref _lifetimeOperationCount); + + // Thread-safe decimal addition using lock + lock (_costLock) + { + _sessionCost += cost; + _lifetimeCost += cost; + } + + // Update breakdown + _costBreakdown.AddOrUpdate( + operationType, + _ => new OperationCostSummary + { + OperationType = operationType, + Count = 1, + TotalInputTokens = inputTokens, + TotalOutputTokens = outputTokens, + TotalCost = cost + }, + (_, existing) => + { + existing.Count++; + existing.TotalInputTokens += inputTokens; + existing.TotalOutputTokens += outputTokens; + existing.TotalCost += cost; + return existing; + }); + + // Auto-save after each operation + SaveState(); + + // Raise event + CostUpdated?.Invoke(this, new CostUpdatedEventArgs( + operationType, + cost, + _sessionCost, + inputTokens, + outputTokens)); + } + + public IReadOnlyDictionary GetCostBreakdown() + { + return _costBreakdown.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + public void ResetSession() + { + _costBreakdown.Clear(); + _sessionCost = 0; + _totalInputTokens = 0; + _totalOutputTokens = 0; + _operationCount = 0; + + CostUpdated?.Invoke(this, new CostUpdatedEventArgs("SessionReset", 0, 0, 0, 0)); + } + + public void ResetAll() + { + ResetSession(); + _lifetimeCost = 0; + _lifetimeInputTokens = 0; + _lifetimeOutputTokens = 0; + _lifetimeOperationCount = 0; + + // Clear persisted data + _persistence?.Remove(KeyLifetimeCost); + _persistence?.Remove(KeyLifetimeOperations); + _persistence?.Remove(KeyLifetimeInputTokens); + _persistence?.Remove(KeyLifetimeOutputTokens); + + CostUpdated?.Invoke(this, new CostUpdatedEventArgs("AllReset", 0, 0, 0, 0)); + } + + public void SaveState() + { + if (_persistence == null) return; + + try + { + _persistence.SetValue(KeyLifetimeCost, _lifetimeCost.ToString("G")); + _persistence.SetValue(KeyLifetimeOperations, _lifetimeOperationCount.ToString()); + _persistence.SetValue(KeyLifetimeInputTokens, _lifetimeInputTokens.ToString()); + _persistence.SetValue(KeyLifetimeOutputTokens, _lifetimeOutputTokens.ToString()); + } + catch + { + // Silently fail - persistence is optional + } + } + + public void LoadState() + { + if (_persistence == null) return; + + try + { + var costStr = _persistence.GetValue(KeyLifetimeCost); + if (!string.IsNullOrEmpty(costStr) && decimal.TryParse(costStr, out var cost)) + { + _lifetimeCost = cost; + } + + var opsStr = _persistence.GetValue(KeyLifetimeOperations); + if (!string.IsNullOrEmpty(opsStr) && int.TryParse(opsStr, out var ops)) + { + _lifetimeOperationCount = ops; + } + + var inputStr = _persistence.GetValue(KeyLifetimeInputTokens); + if (!string.IsNullOrEmpty(inputStr) && int.TryParse(inputStr, out var input)) + { + _lifetimeInputTokens = input; + } + + var outputStr = _persistence.GetValue(KeyLifetimeOutputTokens); + if (!string.IsNullOrEmpty(outputStr) && int.TryParse(outputStr, out var output)) + { + _lifetimeOutputTokens = output; + } + } + catch + { + // Silently fail - start fresh if persistence fails + } + } +} diff --git a/Services/FileStateRepository.cs b/Services/FileStateRepository.cs new file mode 100644 index 0000000..de0bb8d --- /dev/null +++ b/Services/FileStateRepository.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +public sealed class FileStateRepository : IStateRepository +{ + private readonly string _filePath; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public FileStateRepository(string filePath) + { + _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + } + + public async Task LoadAsync(CancellationToken ct = default) + { + if (!File.Exists(_filePath)) + return new ImproverState(); + + try + { + var json = await File.ReadAllTextAsync(_filePath, ct); + return JsonSerializer.Deserialize(json) ?? new ImproverState(); + } + catch (JsonException) + { + return new ImproverState(); + } + catch (IOException) + { + return new ImproverState(); + } + } + + public async Task SaveAsync(ImproverState state, CancellationToken ct = default) + { + // Ensure directory exists + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + state.LastUpdated = DateTimeOffset.UtcNow; + var json = JsonSerializer.Serialize(state, JsonOptions); + await File.WriteAllTextAsync(_filePath, json, ct); + } +} diff --git a/Services/GitDiagnosticService.cs b/Services/GitDiagnosticService.cs new file mode 100755 index 0000000..b5c2eca --- /dev/null +++ b/Services/GitDiagnosticService.cs @@ -0,0 +1,312 @@ +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; + +/// +/// Result of a git diagnosis operation including cost information +/// +public sealed record DiagnosisOperationResult( + GitDiagnosisResult Diagnosis, + int InputTokens = 0, + int OutputTokens = 0, + decimal EstimatedCost = 0); + +/// +/// Service for diagnosing git issues using AI +/// +public interface IGitDiagnosticService +{ + /// + /// Diagnose a git issue based on the current repository state + /// + Task DiagnoseAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default); + + /// + /// Diagnose a git issue and return cost information + /// + Task DiagnoseWithCostAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default); +} + +/// +/// AI-powered git diagnostic service using MarketAlly.AIPlugin +/// +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() + .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 DiagnoseAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default) + { + var result = await DiagnoseWithCostAsync(repoPath, errorMessage, ct); + return result.Diagnosis; + } + + public async Task DiagnoseWithCostAsync(string repoPath, string? errorMessage = null, CancellationToken ct = default) + { + var gitInfo = await GatherGitInfoAsync(repoPath); + var prompt = BuildPrompt(gitInfo, errorMessage); + + var response = await _conversation.SendForStructuredOutputAsync( + 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 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 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 CheckOngoingOperationsAsync(string repoPath) + { + var ops = new List(); + + 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; + } +} diff --git a/Services/GitMessageImproverService.cs b/Services/GitMessageImproverService.cs new file mode 100755 index 0000000..9b893d3 --- /dev/null +++ b/Services/GitMessageImproverService.cs @@ -0,0 +1,759 @@ +using System.Text; +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; +using MarketAlly.GitCommitEditor.Options; +using MarketAlly.GitCommitEditor.Resources; +using MarketAlly.GitCommitEditor.Rewriters; + +namespace MarketAlly.GitCommitEditor.Services; + +public sealed class GitMessageImproverService : IGitMessageImproverService +{ + private readonly GitImproverOptions _options; + private readonly IGitOperationsService _gitOps; + private readonly ICommitMessageAnalyzer _analyzer; + private readonly ICommitMessageRewriter _rewriter; + private readonly IStateRepository _stateRepo; + private readonly IHistoryHealthAnalyzer _healthAnalyzer; + private readonly IHealthReportGenerator _reportGenerator; + private readonly ICleanupExecutor _cleanupExecutor; + private ImproverState _state; + private bool _disposed; + + public GitMessageImproverService( + GitImproverOptions options, + IGitOperationsService gitOps, + ICommitMessageAnalyzer analyzer, + ICommitMessageRewriter rewriter, + IStateRepository stateRepo, + IHistoryHealthAnalyzer? healthAnalyzer = null, + IHealthReportGenerator? reportGenerator = null, + ICleanupExecutor? cleanupExecutor = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(gitOps); + ArgumentNullException.ThrowIfNull(analyzer); + ArgumentNullException.ThrowIfNull(rewriter); + ArgumentNullException.ThrowIfNull(stateRepo); + + _options = options; + _gitOps = gitOps; + _analyzer = analyzer; + _rewriter = rewriter; + _stateRepo = stateRepo; + + // Create default health analysis components if not provided + var commitAnalyzer = new CommitAnalyzer(options.Rules); + _healthAnalyzer = healthAnalyzer ?? new HistoryHealthAnalyzer(commitAnalyzer); + _reportGenerator = reportGenerator ?? new HealthReportGenerator(); + _cleanupExecutor = cleanupExecutor ?? new CleanupExecutor(gitOps, analyzer, rewriter); + _state = new ImproverState(); + } + + /// + /// Creates a service with default implementations. For simple usage without DI. + /// + public static async Task CreateAsync(GitImproverOptions options) + { + options.ValidateAndThrow(); + + var gitOps = new GitOperationsService(); + var analyzer = new CommitMessageAnalyzer(options.Rules); + var stateRepo = new FileStateRepository(options.StateFilePath); + + // Use DynamicCommitRewriter which handles API key changes at runtime + ICommitMessageRewriter rewriter = new DynamicCommitRewriter(options.Ai); + + var service = new GitMessageImproverService(options, gitOps, analyzer, rewriter, stateRepo); + await service.LoadStateAsync(); + return service; + } + + public async Task LoadStateAsync(CancellationToken ct = default) + { + _state = await _stateRepo.LoadAsync(ct); + + // Prune old history entries to prevent unbounded growth + var pruned = _state.PruneHistory(); + var orphaned = _state.RemoveOrphanedHistory(); + + if (pruned > 0 || orphaned > 0) + { + await _stateRepo.SaveAsync(_state, ct); + } + } + + public IReadOnlyList Repos => _state.Repos; + public IReadOnlyList History => _state.History; + + public async Task> ScanAndRegisterReposAsync(CancellationToken ct = default) + { + System.Diagnostics.Debug.WriteLine($"[GitMessageImproverService] Scanning WorkspaceRoot: {_options.WorkspaceRoot}"); + var discovered = _gitOps.DiscoverRepositories(_options.WorkspaceRoot); + var newRepos = new List(); + + foreach (var repoPath in discovered) + { + if (ct.IsCancellationRequested) break; + + if (_state.Repos.Any(r => r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase))) + continue; + + try + { + var managed = _gitOps.CreateManagedRepo(repoPath); + _state.Repos.Add(managed); + newRepos.Add(managed); + } + catch + { + // Skip repos that can't be opened + } + } + + await _stateRepo.SaveAsync(_state, ct); + return newRepos; + } + + public async Task RegisterRepoAsync(string repoPath) + { + var existing = _state.Repos.FirstOrDefault(r => + r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + return existing; + + var managed = _gitOps.CreateManagedRepo(repoPath); + _state.Repos.Add(managed); + await _stateRepo.SaveAsync(_state); + return managed; + } + + public async Task UnregisterRepoAsync(string repoIdOrPath) + { + var repo = _state.Repos.FirstOrDefault(r => + r.Id == repoIdOrPath || + r.Path.Equals(repoIdOrPath, StringComparison.OrdinalIgnoreCase)); + + if (repo == null) return false; + + _state.Repos.Remove(repo); + await _stateRepo.SaveAsync(_state); + return true; + } + + public IEnumerable GetBranches(string repoPath) + { + return _gitOps.GetBranches(repoPath); + } + + public Task CheckoutBranchAsync(ManagedRepo repo, string branchName) + { + return Task.Run(() => + { + try + { + using var repository = new LibGit2Sharp.Repository(repo.Path); + + // Find the branch + var branch = repository.Branches[branchName] + ?? repository.Branches[$"refs/heads/{branchName}"]; + + if (branch == null) + { + return false; + } + + // Check for uncommitted changes + var status = repository.RetrieveStatus(new LibGit2Sharp.StatusOptions()); + if (status.IsDirty) + { + return false; + } + + // Checkout the branch + LibGit2Sharp.Commands.Checkout(repository, branch); + return true; + } + catch + { + return false; + } + }); + } + + public IEnumerable GetBackupBranches(string repoPath) + { + return _gitOps.GetBackupBranches(repoPath); + } + + public bool DeleteBranch(string repoPath, string branchName) + { + return _gitOps.DeleteBranch(repoPath, branchName); + } + + public int DeleteAllBackupBranches(string repoPath) + { + var backupBranches = _gitOps.GetBackupBranches(repoPath).ToList(); + var deletedCount = 0; + + foreach (var branch in backupBranches) + { + if (_gitOps.DeleteBranch(repoPath, branch.Name)) + { + deletedCount++; + } + } + + return deletedCount; + } + + public async Task> AnalyzeAllReposAsync( + bool onlyNeedsImprovement = true, + IProgress<(string Repo, int Processed)>? progress = null, + CancellationToken ct = default) + { + var allAnalyses = new List(); + + foreach (var repo in _state.Repos) + { + if (ct.IsCancellationRequested) break; + + var analyses = AnalyzeRepo(repo); + + if (onlyNeedsImprovement) + { + analyses = analyses.Where(a => a.Quality.NeedsImprovement); + } + + var list = analyses.ToList(); + allAnalyses.AddRange(list); + + repo.LastScannedAt = DateTimeOffset.UtcNow; + repo.LastAnalyzedAt = DateTimeOffset.UtcNow; + repo.CommitsNeedingImprovement = list.Count(a => a.Quality.NeedsImprovement); + + progress?.Report((repo.Name, list.Count)); + } + + await _stateRepo.SaveAsync(_state, ct); + return allAnalyses; + } + + public IEnumerable AnalyzeRepo(ManagedRepo repo) + { + return _gitOps.AnalyzeCommits( + repo, + _analyzer, + _options.MaxCommitsPerRepo, + _options.AnalyzeSince, + _options.ExcludedAuthors); + } + + public CommitAnalysis AnalyzeCommit(string repoPath, string commitHash) + { + var repo = _state.Repos.FirstOrDefault(r => + r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase)) + ?? _gitOps.CreateManagedRepo(repoPath); + + return _gitOps.AnalyzeCommits(repo, _analyzer, 1000) + .First(c => c.CommitHash.StartsWith(commitHash, StringComparison.OrdinalIgnoreCase)); + } + + public async Task UpdateRepoAnalysisAsync(ManagedRepo repo, int totalCommits, int commitsNeedingImprovement, CancellationToken ct = default) + { + repo.LastAnalyzedAt = DateTimeOffset.UtcNow; + repo.LastScannedAt = DateTimeOffset.UtcNow; + repo.TotalCommits = totalCommits; + repo.CommitsNeedingImprovement = commitsNeedingImprovement; + await _stateRepo.SaveAsync(_state, ct); + } + + public async Task GenerateSuggestionsAsync( + IEnumerable analyses, + IProgress? progress = null, + CancellationToken ct = default) + { + var results = await _rewriter.SuggestBatchAsync(analyses, progress, ct); + var failures = new List(); + var successCount = 0; + + foreach (var (analysis, result) in results) + { + analysis.Status = AnalysisStatus.Analyzed; + + if (result.Success && !string.IsNullOrEmpty(result.Suggestion)) + { + analysis.SuggestedMessage = result.Suggestion; + successCount++; + } + else + { + // Don't set SuggestedMessage for failures - leave it empty + failures.Add(new SuggestionFailure + { + CommitHash = analysis.ShortHash, + OriginalMessage = analysis.OriginalMessage, + Reason = result.ErrorMessage ?? Str.Service_UnknownError, + RawResponse = result.RawResponse + }); + } + } + + return new BatchSuggestionResult + { + Analyses = results.Select(r => r.Analysis).ToList(), + SuccessCount = successCount, + FailedCount = failures.Count, + Failures = failures + }; + } + + public async Task GenerateSuggestionAsync(CommitAnalysis analysis, CancellationToken ct = default) + { + var result = await _rewriter.SuggestImprovedMessageAsync(analysis, ct); + analysis.Status = AnalysisStatus.Analyzed; + + if (result.Success && !string.IsNullOrEmpty(result.Suggestion)) + { + analysis.SuggestedMessage = result.Suggestion; + } + + return result; + } + + public IReadOnlyList PreviewChanges(IEnumerable analyses) + { + return analyses + .Where(a => !string.IsNullOrEmpty(a.SuggestedMessage)) + .Select(a => new RewriteOperation + { + RepoId = a.RepoId, + RepoPath = a.RepoPath, + CommitHash = a.CommitHash, + OriginalMessage = a.OriginalMessage, + NewMessage = a.SuggestedMessage!, + IsLatestCommit = a.IsLatestCommit, + Status = OperationStatus.Pending + }) + .ToList(); + } + + public async Task ApplyChangesAsync( + IEnumerable operations, + bool dryRun = true, + IProgress<(int Processed, int Total)>? progress = null, + CancellationToken ct = default) + { + var opList = operations.ToList(); + var results = new List(); + var processed = 0; + var successful = 0; + var failed = 0; + var skipped = 0; + + var byRepo = opList.GroupBy(o => o.RepoPath); + + foreach (var repoGroup in byRepo) + { + if (ct.IsCancellationRequested) break; + + var repo = _state.Repos.FirstOrDefault(r => r.Path == repoGroup.Key); + if (repo == null) + { + foreach (var op in repoGroup) + { + op.Status = OperationStatus.Failed; + op.ErrorMessage = Str.Service_RepoNotRegistered; + results.Add(op); + failed++; + processed++; + } + continue; + } + + var orderedOps = repoGroup.OrderBy(o => o.IsLatestCommit ? 1 : 0).ToList(); + + foreach (var op in orderedOps) + { + if (ct.IsCancellationRequested) + { + op.Status = OperationStatus.Pending; + skipped++; + results.Add(op); + continue; + } + + if (dryRun) + { + op.Status = OperationStatus.Pending; + results.Add(op); + skipped++; + } + else + { + var result = op.IsLatestCommit + ? _gitOps.AmendLatestCommit(repo, op.NewMessage) + : _gitOps.RewordOlderCommit(repo, op.CommitHash, op.NewMessage); + + op.NewCommitHash = result.NewCommitHash; + op.Status = result.Status; + op.ErrorMessage = result.ErrorMessage; + op.AppliedAt = result.AppliedAt; + + results.Add(op); + _state.History.Add(op); + + if (result.Status == OperationStatus.Applied) + successful++; + else + failed++; + } + + processed++; + progress?.Report((processed, opList.Count)); + } + } + + await _stateRepo.SaveAsync(_state, ct); + + return new BatchResult + { + TotalProcessed = processed, + Successful = successful, + Failed = failed, + Skipped = skipped, + Operations = results + }; + } + + public async Task ApplyChangeAsync(CommitAnalysis analysis, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(analysis.SuggestedMessage)) + throw new InvalidOperationException(Str.Service_NoSuggestion); + + var repo = _state.Repos.FirstOrDefault(r => r.Id == analysis.RepoId) + ?? throw new InvalidOperationException(Str.Service_RepoNotFound(analysis.RepoId)); + + var operation = analysis.IsLatestCommit + ? _gitOps.AmendLatestCommit(repo, analysis.SuggestedMessage) + : _gitOps.RewordOlderCommit(repo, analysis.CommitHash, analysis.SuggestedMessage); + + _state.History.Add(operation); + analysis.Status = operation.Status == OperationStatus.Applied + ? AnalysisStatus.Applied + : AnalysisStatus.Failed; + + await _stateRepo.SaveAsync(_state, ct); + return operation; + } + + public bool UndoCommitAmend(string repoPath, string originalCommitHash) + { + return _gitOps.UndoCommitAmend(repoPath, originalCommitHash); + } + + public bool IsCommitPushed(string repoPath, string commitHash) + { + return _gitOps.IsCommitPushed(repoPath, commitHash); + } + + public TrackingInfo GetTrackingInfo(string repoPath) + { + return _gitOps.GetTrackingInfo(repoPath); + } + + public GitPushResult ForcePush(string repoPath) + { + return _gitOps.ForcePush(repoPath); + } + + public GitPushResult Push(string repoPath) + { + return _gitOps.Push(repoPath); + } + + public string GenerateSummaryReport() + { + var sb = new StringBuilder(); + + sb.AppendLine("═══════════════════════════════════════════════════════════════"); + sb.AppendLine("GIT MESSAGE IMPROVER - SUMMARY REPORT"); + sb.AppendLine($"Generated: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine("═══════════════════════════════════════════════════════════════"); + sb.AppendLine(); + + sb.AppendLine($"Total Repositories: {_state.Repos.Count}"); + sb.AppendLine($"Total Operations in History: {_state.History.Count}"); + sb.AppendLine(); + + foreach (var repo in _state.Repos.OrderBy(r => r.Name)) + { + sb.AppendLine($"┌─ {repo.Name}"); + sb.AppendLine($"│ Path: {repo.Path}"); + sb.AppendLine($"│ Branch: {repo.CurrentBranch}"); + sb.AppendLine($"│ Last Scanned: {repo.LastScannedAt?.ToString("yyyy-MM-dd HH:mm") ?? "Never"}"); + sb.AppendLine($"│ Commits Needing Improvement: {repo.CommitsNeedingImprovement}"); + sb.AppendLine($"└────────────────────────────────────────"); + sb.AppendLine(); + } + + var recentOps = _state.History + .OrderByDescending(h => h.CreatedAt) + .Take(10); + + if (recentOps.Any()) + { + sb.AppendLine("RECENT OPERATIONS:"); + sb.AppendLine("─────────────────────────────────────────"); + foreach (var op in recentOps) + { + var status = op.Status switch + { + OperationStatus.Applied => "✓", + OperationStatus.Failed => "✗", + _ => "○" + }; + sb.AppendLine($"{status} [{op.CreatedAt:MM-dd HH:mm}] {op.CommitHash[..7]}: {op.Status}"); + } + } + + return sb.ToString(); + } + + // IHistoryHealthService implementation + + public async Task AnalyzeHistoryHealthAsync( + string repoPath, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + var analysis = await _healthAnalyzer.AnalyzeAsync(repoPath, options, progress, ct); + return _reportGenerator.GenerateReport(analysis); + } + + public async Task AnalyzeHistoryHealthAsync( + ManagedRepo repo, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + var analysis = await _healthAnalyzer.AnalyzeAsync(repo, options, progress, ct); + return _reportGenerator.GenerateReport(analysis); + } + + public Task ExportHealthReportAsync( + HistoryHealthReport report, + ReportFormat format, + CancellationToken ct = default) + { + return _reportGenerator.ExportReportAsync(report, format, ct); + } + + public Task ExportHealthReportToFileAsync( + HistoryHealthReport report, + ReportFormat format, + string outputPath, + CancellationToken ct = default) + { + return _reportGenerator.ExportReportToFileAsync(report, format, outputPath, ct); + } + + public Task ExecuteCleanupAsync( + ManagedRepo repo, + CleanupOperation operation, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + return _cleanupExecutor.ExecuteAsync(repo, operation, options, progress, ct); + } + + public async Task ExecuteAllCleanupsAsync( + ManagedRepo repo, + CleanupSuggestions suggestions, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + // Execute operations in order: automated first, then semi-automated + // Skip manual operations as they require human intervention + var operations = suggestions.AutomatedOperations + .Concat(suggestions.SemiAutomatedOperations) + .ToList(); + + return await _cleanupExecutor.ExecuteBatchAsync(repo, operations, options, progress, ct); + } + + public Task CreateBackupBranchAsync( + ManagedRepo repo, + string? branchName = null, + CancellationToken ct = default) + { + return _cleanupExecutor.CreateBackupBranchAsync(repo, branchName, ct); + } + + public RewriteSafetyInfo GetRewriteSafetyInfo(string repoPath, IEnumerable commits) + { + var commitList = commits.ToList(); + if (!commitList.Any()) + { + return new RewriteSafetyInfo + { + TotalCommitCount = 0, + LocalOnlyCommitCount = 0, + PushedCommitCount = 0 + }; + } + + // Check for uncommitted changes + bool hasUncommittedChanges; + using (var repo = new LibGit2Sharp.Repository(repoPath)) + { + var status = repo.RetrieveStatus(new LibGit2Sharp.StatusOptions()); + hasUncommittedChanges = status.IsDirty; + } + + // Get tracking info + var trackingInfo = _gitOps.GetTrackingInfo(repoPath); + + // Check which commits have been pushed + var pushedCount = 0; + var localCount = 0; + + foreach (var commit in commitList) + { + if (_gitOps.IsCommitPushed(repoPath, commit.CommitHash)) + { + pushedCount++; + } + else + { + localCount++; + } + } + + return new RewriteSafetyInfo + { + HasUncommittedChanges = hasUncommittedChanges, + HasPushedCommits = pushedCount > 0, + PushedCommitCount = pushedCount, + LocalOnlyCommitCount = localCount, + TotalCommitCount = commitList.Count, + HasRemoteTracking = trackingInfo.HasUpstream, + RemoteTrackingBranch = trackingInfo.UpstreamBranch, + AheadOfRemote = trackingInfo.AheadBy, + BehindRemote = trackingInfo.BehindBy + }; + } + + public async Task ExecuteBatchRewriteAsync( + string repoPath, + IEnumerable commits, + bool createBackup = true, + IProgress<(int Current, int Total, string CommitHash)>? progress = null, + CancellationToken ct = default) + { + var commitList = commits + .Where(c => !string.IsNullOrEmpty(c.SuggestedMessage)) + .ToList(); + + if (!commitList.Any()) + { + return BatchRewriteResult.Failure("No commits with suggestions to apply."); + } + + // Get safety info + var safetyInfo = GetRewriteSafetyInfo(repoPath, commitList); + + // Block if uncommitted changes + if (safetyInfo.HasUncommittedChanges) + { + return BatchRewriteResult.Failure(Str.Service_UncommittedChanges); + } + + // Get the managed repo + var repo = _state.Repos.FirstOrDefault(r => r.Path.Equals(repoPath, StringComparison.OrdinalIgnoreCase)); + if (repo == null) + { + return BatchRewriteResult.Failure(Str.Service_RepoNotRegisteredPath(repoPath)); + } + + string? backupBranch = null; + + // Create backup branch if requested + if (createBackup) + { + try + { + backupBranch = await CreateBackupBranchAsync(repo, null, ct); + } + catch (Exception ex) + { + return BatchRewriteResult.Failure($"Failed to create backup branch: {ex.Message}"); + } + } + + var operations = new List(); + var successCount = 0; + var failedCount = 0; + var total = commitList.Count; + + // Process ALL commits (including HEAD) in a single batch operation + // This ensures consistent rewriting without stale references + System.Diagnostics.Debug.WriteLine($"[BatchRewrite] Processing {commitList.Count} commits in single batch"); + + var rewrites = commitList.ToDictionary(c => c.CommitHash, c => c.SuggestedMessage!); + var batchOperations = _gitOps.RewordMultipleCommits(repo, rewrites); + + foreach (var operation in batchOperations) + { + var commit = commitList.First(c => c.CommitHash == operation.CommitHash); + progress?.Report((operations.Count + 1, total, commit.CommitHash[..7])); + + System.Diagnostics.Debug.WriteLine($"[BatchRewrite] Commit {commit.CommitHash[..7]}: Status={operation.Status}, NewHash={operation.NewCommitHash?[..7] ?? "null"}"); + + operations.Add(operation); + _state.History.Add(operation); + + if (operation.Status == OperationStatus.Applied) + { + successCount++; + commit.Status = AnalysisStatus.Applied; + } + else + { + failedCount++; + commit.Status = AnalysisStatus.Failed; + System.Diagnostics.Debug.WriteLine($"[BatchRewrite] FAILED: {operation.ErrorMessage}"); + } + } + + // Invalidate the repository cache to ensure subsequent analysis sees the new commits + _gitOps.InvalidateCache(repoPath); + System.Diagnostics.Debug.WriteLine($"[BatchRewrite] Cache invalidated for {repoPath}"); + + await _stateRepo.SaveAsync(_state, ct); + + return new BatchRewriteResult + { + Success = successCount > 0, + SuccessCount = successCount, + FailedCount = failedCount, + SkippedCount = total - successCount - failedCount, + RequiresForcePush = safetyInfo.HasPushedCommits && successCount > 0, + BackupBranchName = backupBranch, + Operations = operations + }; + } + + public void Dispose() + { + if (_disposed) return; + + _gitOps.Dispose(); + (_rewriter as IDisposable)?.Dispose(); + _disposed = true; + } +} diff --git a/Services/GitOperationsService.cs b/Services/GitOperationsService.cs new file mode 100755 index 0000000..d34ff62 --- /dev/null +++ b/Services/GitOperationsService.cs @@ -0,0 +1,1000 @@ +using System.Collections.Concurrent; +using MarketAlly.LibGit2Sharp; +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Services; + +public sealed class GitOperationsService : IGitOperationsService +{ + private readonly ConcurrentDictionary _repoCache = new(); + private readonly Timer _cleanupTimer; + private readonly int _maxCacheSize; + private readonly TimeSpan _cacheTtl; + private bool _disposed; + + private sealed class CachedRepository(Repository repository) + { + public Repository Repository { get; } = repository; + public DateTime LastAccessed { get; set; } = DateTime.UtcNow; + } + + public GitOperationsService(int maxCacheSize = 20, TimeSpan? cacheTtl = null) + { + _maxCacheSize = maxCacheSize; + _cacheTtl = cacheTtl ?? TimeSpan.FromMinutes(30); + _cleanupTimer = new Timer(CleanupExpiredRepositories, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + } + + private void CleanupExpiredRepositories(object? state) + { + var expiredKeys = _repoCache + .Where(kvp => DateTime.UtcNow - kvp.Value.LastAccessed > _cacheTtl) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in expiredKeys) + { + if (_repoCache.TryRemove(key, out var cached)) + { + cached.Repository.Dispose(); + } + } + } + + /// + /// Normalizes a path for consistent cache key comparison. + /// On Windows, paths are case-insensitive, so we normalize to lowercase. + /// + private static string NormalizePath(string path) + { + // Get full path to resolve any relative paths or ../ segments + var fullPath = Path.GetFullPath(path); + + // On Windows, normalize to lowercase for case-insensitive comparison + if (OperatingSystem.IsWindows()) + { + return fullPath.ToLowerInvariant(); + } + + return fullPath; + } + + private Repository GetRepository(string path) + { + var normalizedPath = NormalizePath(path); + + // Evict oldest if at capacity + while (_repoCache.Count >= _maxCacheSize) + { + var oldest = _repoCache + .OrderBy(kvp => kvp.Value.LastAccessed) + .FirstOrDefault(); + + if (oldest.Key != null && _repoCache.TryRemove(oldest.Key, out var removed)) + { + removed.Repository.Dispose(); + } + } + + // Use normalized path for cache key, but original path for Repository constructor + var cached = _repoCache.GetOrAdd(normalizedPath, _ => new CachedRepository(new Repository(path))); + cached.LastAccessed = DateTime.UtcNow; + return cached.Repository; + } + + /// + /// Invalidates the cached Repository for the given path, forcing a fresh instance on next access. + /// Call this after operations that modify git history (rewrite, rebase, etc.) + /// + public void InvalidateCache(string path) + { + var normalizedPath = NormalizePath(path); + if (_repoCache.TryRemove(normalizedPath, out var cached)) + { + cached.Repository.Dispose(); + } + } + + public IEnumerable DiscoverRepositories(string rootPath, int maxDepth = 3) + { + ArgumentNullException.ThrowIfNull(rootPath); + + if (!Directory.Exists(rootPath)) + yield break; + + var queue = new Queue<(string Path, int Depth)>(); + queue.Enqueue((rootPath, 0)); + + while (queue.Count > 0) + { + var (currentPath, depth) = queue.Dequeue(); + + var gitDir = Path.Combine(currentPath, ".git"); + if (Directory.Exists(gitDir) || File.Exists(gitDir)) + { + yield return currentPath; + continue; + } + + if (depth >= maxDepth) + continue; + + try + { + foreach (var subDir in Directory.GetDirectories(currentPath)) + { + var name = Path.GetFileName(subDir); + if (name.StartsWith('.') || + name is "node_modules" or "bin" or "obj" or "packages" or ".git") + continue; + + queue.Enqueue((subDir, depth + 1)); + } + } + catch (UnauthorizedAccessException) + { + // Skip directories we can't access + } + catch (DirectoryNotFoundException) + { + // Skip directories that were deleted during enumeration + } + } + } + + public ManagedRepo CreateManagedRepo(string repoPath) + { + ArgumentNullException.ThrowIfNull(repoPath); + + using var repo = new Repository(repoPath); + + var remoteUrl = repo.Network.Remotes.FirstOrDefault()?.Url; + var currentBranch = repo.Head.FriendlyName; + var name = Path.GetFileName(repoPath); + + return new ManagedRepo + { + Name = name, + Path = repoPath, + RemoteUrl = remoteUrl, + CurrentBranch = currentBranch + }; + } + + public IEnumerable GetBranches(string repoPath) + { + ArgumentNullException.ThrowIfNull(repoPath); + + var repo = GetRepository(repoPath); + + foreach (var branch in repo.Branches) + { + yield return new BranchInfo + { + Name = branch.FriendlyName, + FullName = branch.CanonicalName, + IsRemote = branch.IsRemote, + IsCurrentHead = branch.IsCurrentRepositoryHead, + LastCommitSha = branch.Tip?.Sha, + LastCommitDate = branch.Tip?.Author.When, + RemoteName = branch.IsRemote ? branch.RemoteName : null + }; + } + } + + public IEnumerable AnalyzeCommits( + ManagedRepo managedRepo, + ICommitMessageAnalyzer analyzer, + int maxCommits = 100, + DateTimeOffset? since = null, + string[]? excludeAuthors = null) + { + ArgumentNullException.ThrowIfNull(managedRepo); + ArgumentNullException.ThrowIfNull(analyzer); + + // Always invalidate cache before analyzing to ensure we see the latest commits + // This is critical after rewrite operations that change commit history + InvalidateCache(managedRepo.Path); + + var repo = GetRepository(managedRepo.Path); + var headTip = repo.Head.Tip; + var headSha = headTip?.Sha; + + if (headTip == null) + yield break; + + // Explicitly walk from HEAD to avoid picking up unreachable commits + // After a rewrite, old commits still exist in the object database but are unreachable from HEAD + var commitFilter = new CommitFilter + { + IncludeReachableFrom = headTip, + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time + }; + + var commits = since.HasValue + ? repo.Commits.QueryBy(commitFilter).Where(c => c.Author.When >= since.Value).Take(maxCommits) + : repo.Commits.QueryBy(commitFilter).Take(maxCommits); + + foreach (var commit in commits) + { + if (excludeAuthors?.Contains(commit.Author.Email, StringComparer.OrdinalIgnoreCase) == true) + continue; + + var parent = commit.Parents.FirstOrDefault(); + var changes = repo.Diff.Compare(parent?.Tree, commit.Tree); + var patch = repo.Diff.Compare(parent?.Tree, commit.Tree); + + var context = new CommitContext + { + FilesChanged = changes.Count(), + LinesAdded = patch.LinesAdded, + LinesDeleted = patch.LinesDeleted, + FileNames = changes.Select(c => c.Path).ToList() + }; + + var quality = analyzer.Analyze(commit.Message, context); + + // Build per-file diffs dictionary + var fileDiffs = new Dictionary(); + foreach (var patchEntry in patch) + { + fileDiffs[patchEntry.Path] = TruncateDiff(patchEntry.Patch, 100); + } + + yield return new CommitAnalysis + { + RepoId = managedRepo.Id, + RepoName = managedRepo.Name, + RepoPath = managedRepo.Path, + CommitHash = commit.Sha, + OriginalMessage = commit.Message.Trim(), + CommitDate = commit.Author.When, + Author = commit.Author.Name, + AuthorEmail = commit.Author.Email, + Quality = quality, + FilesChanged = changes.Select(c => c.Path).ToList(), + DiffSummary = TruncateDiff(patch.Content, 200), + FileDiffs = fileDiffs, + LinesAdded = patch.LinesAdded, + LinesDeleted = patch.LinesDeleted, + IsLatestCommit = commit.Sha == headSha + }; + } + } + + public RewriteOperation AmendLatestCommit(ManagedRepo managedRepo, string newMessage) + { + ArgumentNullException.ThrowIfNull(managedRepo); + ArgumentNullException.ThrowIfNull(newMessage); + + var repo = GetRepository(managedRepo.Path); + var head = repo.Head.Tip ?? throw new InvalidOperationException(Str.Git_NoCommits); + + var operation = new RewriteOperation + { + RepoId = managedRepo.Id, + RepoPath = managedRepo.Path, + CommitHash = head.Sha, + OriginalMessage = head.Message, + NewMessage = newMessage, + IsLatestCommit = true + }; + + try + { + var amended = repo.Commit( + newMessage, + head.Author, + head.Committer, + new CommitOptions { AmendPreviousCommit = true }); + + operation.NewCommitHash = amended.Sha; + operation.Status = OperationStatus.Applied; + operation.AppliedAt = DateTimeOffset.UtcNow; + } + catch (LibGit2SharpException ex) + { + operation.Status = OperationStatus.Failed; + operation.ErrorMessage = Str.Git_Error(ex.Message); + } + catch (Exception ex) + { + operation.Status = OperationStatus.Failed; + operation.ErrorMessage = ex.Message; + } + + return operation; + } + + public RewriteOperation RewordOlderCommit(ManagedRepo managedRepo, string commitHash, string newMessage) + { + ArgumentNullException.ThrowIfNull(managedRepo); + ArgumentNullException.ThrowIfNull(commitHash); + ArgumentNullException.ThrowIfNull(newMessage); + + var repo = GetRepository(managedRepo.Path); + + var targetCommit = repo.Lookup(commitHash) + ?? throw new ArgumentException(Str.Git_CommitNotFound(commitHash), nameof(commitHash)); + + var operation = new RewriteOperation + { + RepoId = managedRepo.Id, + RepoPath = managedRepo.Path, + CommitHash = commitHash, + OriginalMessage = targetCommit.Message, + NewMessage = newMessage, + IsLatestCommit = repo.Head.Tip?.Sha == commitHash + }; + + if (operation.IsLatestCommit) + { + return AmendLatestCommit(managedRepo, newMessage); + } + + try + { + var commitsToRewrite = repo.Commits + .QueryBy(new CommitFilter + { + IncludeReachableFrom = repo.Head.Tip, + ExcludeReachableFrom = targetCommit.Parents + }) + .Reverse() + .ToList(); + + if (!commitsToRewrite.Any(c => c.Sha == targetCommit.Sha)) + throw new InvalidOperationException(Str.Git_NotAncestor); + + var rewriteMap = new Dictionary(); + Commit? newHead = null; + + foreach (var oldCommit in commitsToRewrite) + { + var message = oldCommit.Sha == targetCommit.Sha + ? newMessage + : oldCommit.Message; + + var newParents = oldCommit.Parents + .Select(p => rewriteMap.TryGetValue(p.Sha, out var mapped) ? mapped : p) + .ToList(); + + var newCommit = repo.ObjectDatabase.CreateCommit( + oldCommit.Author, + oldCommit.Committer, + message, + oldCommit.Tree, + newParents, + prettifyMessage: false); + + rewriteMap[oldCommit.Sha] = newCommit; + newHead = newCommit; + } + + if (newHead != null) + { + repo.Refs.UpdateTarget(repo.Head.CanonicalName, newHead.Id.Sha); + } + + operation.NewCommitHash = rewriteMap.TryGetValue(commitHash, out var rewritten) + ? rewritten.Sha + : null; + operation.Status = OperationStatus.Applied; + operation.AppliedAt = DateTimeOffset.UtcNow; + } + catch (LibGit2SharpException ex) + { + operation.Status = OperationStatus.Failed; + operation.ErrorMessage = Str.Git_Error(ex.Message); + } + catch (Exception ex) + { + operation.Status = OperationStatus.Failed; + operation.ErrorMessage = ex.Message; + } + + return operation; + } + + public List RewordMultipleCommits(ManagedRepo managedRepo, Dictionary rewrites) + { + ArgumentNullException.ThrowIfNull(managedRepo); + ArgumentNullException.ThrowIfNull(rewrites); + + var operations = new List(); + + if (rewrites.Count == 0) + return operations; + + // Disable libgit2's internal object cache to prevent stale objects during rewrite + GlobalSettings.SetEnableCaching(false); + + // Use a fresh Repository (not cached) to ensure changes are flushed to disk + // The cached repo might have stale state or not flush properly + InvalidateCache(managedRepo.Path); + var repo = new Repository(managedRepo.Path); + + // Validate all commits exist and build operation list + // Cache OriginalMessage now because commit objects become stale after cache invalidation + var commitInfos = new List<(string OriginalHash, string OriginalMessage, string NewMessage)>(); + foreach (var (hash, newMessage) in rewrites) + { + var commit = repo.Lookup(hash); + if (commit == null) + { + operations.Add(new RewriteOperation + { + RepoId = managedRepo.Id, + RepoPath = managedRepo.Path, + CommitHash = hash, + OriginalMessage = "", + NewMessage = newMessage, + Status = OperationStatus.Failed, + ErrorMessage = Str.Git_CommitNotFound(hash) + }); + continue; + } + commitInfos.Add((hash, commit.Message, newMessage)); + } + + if (commitInfos.Count == 0) + return operations; + + try + { + // Find the oldest commit in git ancestry by walking from HEAD + // The commit that appears LAST when walking from HEAD is the oldest ancestor + var commitHashes = new HashSet(commitInfos.Select(c => c.OriginalHash)); + + Commit? oldestCommit = null; + foreach (var commit in repo.Commits.QueryBy(new CommitFilter { IncludeReachableFrom = repo.Head.Tip })) + { + if (commitHashes.Contains(commit.Sha)) + { + oldestCommit = commit; // Keep updating - last one found is oldest + } + } + + // Fallback: if none found in walk (shouldn't happen), look up first commit + if (oldestCommit == null && commitInfos.Count > 0) + { + oldestCommit = repo.Lookup(commitInfos[0].OriginalHash); + } + + if (oldestCommit == null) + { + throw new InvalidOperationException(Str.Git_NoTargetCommits); + } + + // Get all commits from oldest's parents to HEAD + // CRITICAL: Use Topological+Reverse to ensure parents come before children + // This is essential for rewriting - we must process parents first so their + // new hashes are in rewriteMap when we process their children + // Topological alone = children before parents (git default for log) + // Topological | Reverse = parents before children (what we need for rewriting) + var commitsToRewrite = repo.Commits + .QueryBy(new CommitFilter + { + IncludeReachableFrom = repo.Head.Tip, + ExcludeReachableFrom = oldestCommit.Parents, + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Reverse + }) + .ToList(); + + // Build a lookup of commits to rewrite + var rewriteLookup = commitInfos.ToDictionary(c => c.OriginalHash, c => c); + + // Rewrite all commits in a single pass + var rewriteMap = new Dictionary(); + Commit? newHead = null; + + foreach (var oldCommit in commitsToRewrite) + { + // Use new message if this commit is being rewritten, otherwise keep original + var message = rewriteLookup.TryGetValue(oldCommit.Sha, out var info) + ? info.NewMessage + : oldCommit.Message; + + // Map parents to their rewritten versions + var newParents = oldCommit.Parents + .Select(p => rewriteMap.TryGetValue(p.Sha, out var mapped) ? mapped : p) + .ToList(); + + var newCommit = repo.ObjectDatabase.CreateCommit( + oldCommit.Author, + oldCommit.Committer, + message, + oldCommit.Tree, + newParents, + prettifyMessage: false); + + // Verify the created commit has the correct parents + var createdParents = newCommit.Parents.Select(p => p.Sha).ToList(); + var expectedParents = newParents.Select(p => p.Sha).ToList(); + if (!createdParents.SequenceEqual(expectedParents)) + { + throw new InvalidOperationException(Str.Git_ParentMismatch(oldCommit.Sha[..7])); + } + + rewriteMap[oldCommit.Sha] = newCommit; + newHead = newCommit; + } + + // Update HEAD to new commit chain using hard reset + // This is more robust than just updating refs because it also syncs index and working tree + if (newHead != null) + { + // Hard reset updates HEAD, index, AND working tree atomically + repo.Reset(ResetMode.Hard, newHead); + + // Verify the HEAD was actually updated + var verifyHead = repo.Head.Tip; + if (verifyHead?.Sha != newHead.Sha) + { + throw new InvalidOperationException(Str.Git_HeadUpdateFailed(newHead.Sha[..7])); + } + } + + // Store the expected new HEAD SHA before disposing (null if no commits rewritten) + var expectedHeadSha = newHead?.Sha; + + // Explicitly dispose repository to flush changes to disk + repo.Dispose(); + repo = null!; + + // Invalidate cache to ensure fresh state + InvalidateCache(managedRepo.Path); + + // Verify the changes persisted to disk and no old commits are reachable + if (expectedHeadSha != null) + { + using (var verifyRepo = new Repository(managedRepo.Path)) + { + var diskHead = verifyRepo.Head.Tip; + if (diskHead?.Sha != expectedHeadSha) + { + throw new InvalidOperationException(Str.Git_VerificationFailed(expectedHeadSha[..7], diskHead?.Sha?[..7] ?? "null")); + } + + // Walk the new chain and verify no original hashes appear + var originalHashes = new HashSet(commitsToRewrite.Select(c => c.Sha)); + var walkCount = 0; + var maxWalk = commitsToRewrite.Count + 10; + foreach (var c in verifyRepo.Commits.QueryBy(new CommitFilter { IncludeReachableFrom = diskHead })) + { + if (walkCount++ > maxWalk) break; + + if (originalHashes.Contains(c.Sha)) + { + throw new InvalidOperationException(Str.Git_OldCommitReachable(c.Sha[..7])); + } + } + } + } + + // Build successful operation results + foreach (var (originalHash, originalMessage, newMessage) in commitInfos) + { + var newHash = rewriteMap.TryGetValue(originalHash, out var rewritten) + ? rewritten.Sha + : null; + + operations.Add(new RewriteOperation + { + RepoId = managedRepo.Id, + RepoPath = managedRepo.Path, + CommitHash = originalHash, + OriginalMessage = originalMessage, + NewMessage = newMessage, + NewCommitHash = newHash, + Status = OperationStatus.Applied, + AppliedAt = DateTimeOffset.UtcNow + }); + } + + // Re-enable libgit2 caching after successful rewrite + GlobalSettings.SetEnableCaching(true); + } + catch (LibGit2SharpException ex) + { + // Ensure repo is disposed even on error + repo?.Dispose(); + InvalidateCache(managedRepo.Path); + + // Re-enable libgit2 caching on error + GlobalSettings.SetEnableCaching(true); + + // Mark all remaining as failed + foreach (var (originalHash, originalMessage, newMessage) in commitInfos) + { + if (!operations.Any(o => o.CommitHash == originalHash)) + { + operations.Add(new RewriteOperation + { + RepoId = managedRepo.Id, + RepoPath = managedRepo.Path, + CommitHash = originalHash, + OriginalMessage = originalMessage, + NewMessage = newMessage, + Status = OperationStatus.Failed, + ErrorMessage = Str.Git_Error(ex.Message) + }); + } + } + } + catch (Exception ex) + { + // Ensure repo is disposed even on error + repo?.Dispose(); + InvalidateCache(managedRepo.Path); + + // Re-enable libgit2 caching on error + GlobalSettings.SetEnableCaching(true); + + foreach (var (originalHash, originalMessage, newMessage) in commitInfos) + { + if (!operations.Any(o => o.CommitHash == originalHash)) + { + operations.Add(new RewriteOperation + { + RepoId = managedRepo.Id, + RepoPath = managedRepo.Path, + CommitHash = originalHash, + OriginalMessage = originalMessage, + NewMessage = newMessage, + Status = OperationStatus.Failed, + ErrorMessage = ex.Message + }); + } + } + } + + return operations; + } + + public bool IsCommitPushed(string repoPath, string commitHash) + { + ArgumentNullException.ThrowIfNull(repoPath); + ArgumentNullException.ThrowIfNull(commitHash); + + try + { + var repo = GetRepository(repoPath); + var commit = repo.Lookup(commitHash); + if (commit == null) return false; + + var head = repo.Head; + if (!head.IsTracking || head.TrackedBranch?.Tip == null) + return false; + + var remoteTip = head.TrackedBranch.Tip; + var filter = new CommitFilter { IncludeReachableFrom = remoteTip }; + + return repo.Commits.QueryBy(filter).Any(c => c.Sha == commitHash); + } + catch (LibGit2SharpException) + { + return false; + } + } + + public TrackingInfo GetTrackingInfo(string repoPath) + { + ArgumentNullException.ThrowIfNull(repoPath); + + try + { + var repo = GetRepository(repoPath); + var head = repo.Head; + + if (!head.IsTracking) + return TrackingInfo.None; + + var tracking = head.TrackingDetails; + return new TrackingInfo( + head.TrackedBranch?.FriendlyName, + tracking.AheadBy, + tracking.BehindBy); + } + catch (LibGit2SharpException) + { + return TrackingInfo.None; + } + } + + public GitPushResult ForcePush(string repoPath, PushOptions? options = null) + { + ArgumentNullException.ThrowIfNull(repoPath); + + try + { + var repo = GetRepository(repoPath); + var head = repo.Head; + var branchName = head.FriendlyName; + + Remote remote; + string forceRefSpec; + + if (head.IsTracking) + { + // Branch has upstream - force push to tracked remote + remote = repo.Network.Remotes[head.RemoteName]; + if (remote == null) + return GitPushResult.Fail(Str.Git_RemoteNotFound(head.RemoteName)); + + forceRefSpec = $"+{head.CanonicalName}:{head.UpstreamBranchCanonicalName}"; + } + else + { + // No upstream - try to push to origin + remote = repo.Network.Remotes["origin"]; + if (remote == null) + return GitPushResult.Fail(Str.Git_NoUpstreamNoOrigin(branchName)); + + // Force push to origin with the same branch name + forceRefSpec = $"+{head.CanonicalName}:refs/heads/{branchName}"; + } + + // Use provided options or create default with credentials handler + var pushOptions = options ?? CreateDefaultPushOptions(); + + repo.Network.Push(remote, forceRefSpec, pushOptions); + + return GitPushResult.Ok(head.IsTracking + ? Str.Git_ForcePushSuccess + : Str.Git_ForcePushedTo(branchName)); + } + catch (LibGit2SharpException ex) + { + // Provide more helpful error message for auth failures + if (ex.Message.Contains("authentication") || ex.Message.Contains("credential")) + { + // Try using system git as fallback + return ForcePushViaGitCommand(repoPath); + } + return GitPushResult.Fail(Str.Git_Error(ex.Message)); + } + catch (Exception ex) + { + return GitPushResult.Fail(ex.Message); + } + } + + private static GitPushResult ForcePushViaGitCommand(string repoPath) + { + try + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "git", + Arguments = "push --force-with-lease", + WorkingDirectory = repoPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(startInfo); + if (process == null) + return GitPushResult.Fail(Str.Git_ProcessFailed); + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(30000); // 30 second timeout + + if (process.ExitCode == 0) + { + return GitPushResult.Ok(Str.Git_ForcePushSuccessCmd); + } + else + { + // Git often outputs progress to stderr even on success + var message = !string.IsNullOrEmpty(error) ? error : output; + return GitPushResult.Fail(Str.Git_PushFailed(message.Trim())); + } + } + catch (Exception ex) + { + return GitPushResult.Fail(Str.Git_CommandFailed(ex.Message)); + } + } + + private static PushOptions CreateDefaultPushOptions() + { + return new PushOptions + { + CredentialsProvider = (url, usernameFromUrl, types) => + { + // Try to use default credentials (works with Git Credential Manager on Windows) + return new DefaultCredentials(); + } + }; + } + + public GitPushResult Push(string repoPath, PushOptions? options = null) + { + ArgumentNullException.ThrowIfNull(repoPath); + + try + { + var repo = GetRepository(repoPath); + var head = repo.Head; + + if (!head.IsTracking) + return GitPushResult.Fail(Str.Git_NoUpstream); + + var pushOptions = options ?? CreateDefaultPushOptions(); + repo.Network.Push(head, pushOptions); + return GitPushResult.Ok(); + } + catch (NonFastForwardException) + { + return GitPushResult.Fail(Str.Git_NonFastForward); + } + catch (LibGit2SharpException ex) + { + if (ex.Message.Contains("authentication") || ex.Message.Contains("credential")) + { + // Try using system git as fallback + return PushViaGitCommand(repoPath); + } + return GitPushResult.Fail(Str.Git_Error(ex.Message)); + } + catch (Exception ex) + { + return GitPushResult.Fail(ex.Message); + } + } + + private static GitPushResult PushViaGitCommand(string repoPath) + { + try + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "git", + Arguments = "push", + WorkingDirectory = repoPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(startInfo); + if (process == null) + return GitPushResult.Fail(Str.Git_ProcessFailed); + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(30000); + + if (process.ExitCode == 0) + { + return GitPushResult.Ok(Str.Git_PushSuccessCmd); + } + else + { + var message = !string.IsNullOrEmpty(error) ? error : output; + return GitPushResult.Fail(Str.Git_PushFailed(message.Trim())); + } + } + catch (Exception ex) + { + return GitPushResult.Fail(Str.Git_CommandFailed(ex.Message)); + } + } + + public bool UndoCommitAmend(string repoPath, string originalCommitHash) + { + ArgumentNullException.ThrowIfNull(repoPath); + ArgumentNullException.ThrowIfNull(originalCommitHash); + + try + { + var repo = GetRepository(repoPath); + var originalCommit = repo.Lookup(originalCommitHash); + + if (originalCommit == null) + { + var reflog = repo.Refs.Log(repo.Head.CanonicalName); + var entry = reflog?.FirstOrDefault(e => e.To.Sha == originalCommitHash); + if (entry != null) + { + originalCommit = repo.Lookup(entry.To); + } + } + + if (originalCommit == null) + return false; + + repo.Reset(ResetMode.Soft, originalCommit); + return true; + } + catch (LibGit2SharpException) + { + return false; + } + } + + private static string TruncateDiff(string diff, int maxLines) + { + var lines = diff.Split('\n'); + if (lines.Length <= maxLines) + return diff; + + return string.Join('\n', lines.Take(maxLines)) + $"\n... ({lines.Length - maxLines} more lines)"; + } + + public IEnumerable GetBackupBranches(string repoPath) + { + ArgumentNullException.ThrowIfNull(repoPath); + + // Use a fresh repository to ensure we get the latest branch state + // Don't use cache here as branches may have been deleted + using var repo = new Repository(repoPath); + var branches = repo.Branches + .Where(b => !b.IsRemote && b.FriendlyName.StartsWith("backup/")) + .ToList(); // Materialize to avoid enumeration issues with using + + foreach (var branch in branches) + { + yield return new BackupBranchInfo( + branch.FriendlyName, + branch.CanonicalName, + branch.Tip?.Author.When, + branch.Tip?.Sha); + } + } + + public bool DeleteBranch(string repoPath, string branchName) + { + ArgumentNullException.ThrowIfNull(repoPath); + ArgumentNullException.ThrowIfNull(branchName); + + // Clear the cached repository BEFORE deletion to avoid stale state + if (_repoCache.TryRemove(repoPath, out var cached)) + { + cached.Repository.Dispose(); + } + + try + { + // Use a fresh repository instance + using var repo = new Repository(repoPath); + var branch = repo.Branches[branchName]; + + if (branch == null) + return false; + + if (branch.IsCurrentRepositoryHead) + return false; // Can't delete current branch + + repo.Branches.Remove(branch); + + // Verify the branch was actually deleted + var verifyBranch = repo.Branches[branchName]; + return verifyBranch == null; + } + catch (LibGit2SharpException) + { + return false; + } + } + + public void Dispose() + { + if (_disposed) return; + + _cleanupTimer.Dispose(); + + foreach (var cached in _repoCache.Values) + { + cached.Repository.Dispose(); + } + _repoCache.Clear(); + _disposed = true; + } +} diff --git a/Services/HealthReportGenerator.cs b/Services/HealthReportGenerator.cs new file mode 100644 index 0000000..a5d736d --- /dev/null +++ b/Services/HealthReportGenerator.cs @@ -0,0 +1,761 @@ +using System.Text; +using System.Text.Json; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; +using MarketAlly.GitCommitEditor.Resources; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Generates health reports and calculates scores. +/// +public sealed class HealthReportGenerator : IHealthReportGenerator +{ + private readonly HealthScoringWeights _weights; + + public HealthReportGenerator(HealthScoringWeights? weights = null) + { + _weights = weights ?? HealthScoringWeights.Default; + } + + public HistoryHealthReport GenerateReport(HistoryHealthAnalysis analysis) + { + var score = CalculateScore(analysis); + var issues = DetectIssues(analysis); + var recommendations = GenerateRecommendations(issues, analysis); + var cleanup = GenerateCleanupSuggestions(issues, analysis); + + return new HistoryHealthReport + { + RepoId = analysis.RepoPath, + RepoName = analysis.RepoName, + RepoPath = analysis.RepoPath, + CurrentBranch = analysis.CurrentBranch, + CommitsAnalyzed = analysis.CommitsAnalyzed, + Score = score, + DuplicateMetrics = analysis.Duplicates, + MergeMetrics = analysis.MergeMetrics, + BranchMetrics = analysis.BranchMetrics, + MessageDistribution = analysis.MessageDistribution, + AuthorshipMetrics = analysis.AuthorshipMetrics, + Issues = issues, + Recommendations = recommendations, + CleanupSuggestions = cleanup + }; + } + + private HealthScore CalculateScore(HistoryHealthAnalysis analysis) + { + var duplicateScore = CalculateDuplicateScore(analysis.Duplicates); + var mergeScore = CalculateMergeScore(analysis.MergeMetrics); + var branchScore = CalculateBranchScore(analysis.BranchMetrics); + var messageScore = CalculateMessageScore(analysis.MessageDistribution); + var authorshipScore = CalculateAuthorshipScore(analysis.AuthorshipMetrics); + + var overallScore = (int)Math.Round( + duplicateScore * _weights.DuplicateWeight + + mergeScore * _weights.MergeWeight + + branchScore * _weights.BranchWeight + + messageScore * _weights.MessageWeight + + authorshipScore * _weights.AuthorshipWeight + ); + + overallScore = Math.Clamp(overallScore, 0, 100); + + return new HealthScore + { + OverallScore = overallScore, + Grade = HealthGradeExtensions.FromScore(overallScore), + ComponentScores = new ComponentScores + { + DuplicateScore = duplicateScore, + MergeScore = mergeScore, + BranchScore = branchScore, + MessageScore = messageScore, + AuthorshipScore = authorshipScore + } + }; + } + + private int CalculateDuplicateScore(DuplicateCommitMetrics metrics) + { + if (metrics.TotalDuplicateGroups == 0) + return 100; + + var duplicateRatio = metrics.DuplicateRatio; + var score = 100 - (int)(duplicateRatio * 10); + var exactPenalty = metrics.ExactDuplicates * 5; + + return Math.Max(0, score - exactPenalty); + } + + private int CalculateMergeScore(MergeCommitMetrics metrics) + { + var mergeRatio = metrics.MergeRatio; + var baseScore = mergeRatio switch + { + <= 10 => 100, + <= 25 => 90, + <= 35 => 75, + <= 50 => 50, + _ => 25 + }; + + // Additional penalty for merge fix commits + var fixPenalty = Math.Min(20, metrics.MergeFixCommits * 3); + + return Math.Max(0, baseScore - fixPenalty); + } + + private int CalculateBranchScore(BranchComplexityMetrics metrics) + { + var baseScore = metrics.Topology switch + { + BranchTopologyType.Linear => 100, + BranchTopologyType.Balanced => 90, + BranchTopologyType.GitFlow => 75, + BranchTopologyType.Tangled => 50, + BranchTopologyType.Spaghetti => 25, + _ => 50 + }; + + var stalePenalty = Math.Min(20, metrics.StaleBranches * 2); + var crossMergePenalty = Math.Min(25, metrics.CrossMerges * 2); + + return Math.Max(0, baseScore - stalePenalty - crossMergePenalty); + } + + private int CalculateMessageScore(MessageQualityDistribution distribution) + { + if (distribution.TotalCommits == 0) + return 100; + + var avgScore = (int)distribution.AverageScore; + var poorRatio = (double)distribution.Poor / distribution.TotalCommits; + var poorPenalty = (int)(poorRatio * 30); + + return Math.Max(0, avgScore - poorPenalty); + } + + private int CalculateAuthorshipScore(AuthorshipMetrics metrics) + { + if (metrics.TotalCommits == 0) + return 100; + + var missingEmailRatio = (double)metrics.MissingEmailCount / metrics.TotalCommits; + var invalidEmailRatio = (double)metrics.InvalidEmailCount / metrics.TotalCommits; + + var score = 100 - (int)((missingEmailRatio + invalidEmailRatio) * 100); + return Math.Max(0, score); + } + + private List DetectIssues(HistoryHealthAnalysis analysis) + { + var issues = new List(); + + // Duplicate issues - separate ExactTree (true duplicates) from ExactMessage (just same message) + var exactTreeGroups = analysis.Duplicates.DuplicateGroups + .Where(g => g.Type == DuplicateType.ExactTree) + .ToList(); + var exactMessageGroups = analysis.Duplicates.DuplicateGroups + .Where(g => g.Type == DuplicateType.ExactMessage) + .ToList(); + + // ExactTree duplicates - these are TRUE duplicates that can be safely squashed + if (exactTreeGroups.Count > 0) + { + var exactTreeInstances = exactTreeGroups.Sum(g => g.InstanceCount - 1); + var severity = exactTreeInstances > 5 + ? HealthIssueSeverity.Error + : exactTreeGroups.Count > 2 + ? HealthIssueSeverity.Warning + : HealthIssueSeverity.Info; + + issues.Add(new HealthIssue + { + Code = "DUPLICATE_COMMITS", + Category = "Duplicates", + Severity = severity, + Title = Str.Report_DuplicateContent, + Description = Str.Report_DuplicateContentDesc(exactTreeGroups.Count, exactTreeInstances), + ImpactScore = exactTreeInstances * 5 + exactTreeGroups.Count * 2, + AffectedCommits = exactTreeGroups + .SelectMany(g => g.CommitHashes) + .Take(20) + .ToList() + }); + } + + // ExactMessage duplicates - these have same message but DIFFERENT code, DO NOT squash + if (exactMessageGroups.Count > 0) + { + var messageInstances = exactMessageGroups.Sum(g => g.InstanceCount); + issues.Add(new HealthIssue + { + Code = "SIMILAR_MESSAGES", + Category = "Messages", + Severity = HealthIssueSeverity.Info, + Title = Str.Report_DuplicateMessages, + Description = Str.Report_DuplicateMessagesDesc(exactMessageGroups.Count, messageInstances), + ImpactScore = exactMessageGroups.Count, // Low impact - just informational + AffectedCommits = exactMessageGroups + .SelectMany(g => g.CommitHashes) + .Take(20) + .ToList() + }); + } + + // Merge issues + var mergeRatio = analysis.MergeMetrics.MergeRatio; + if (mergeRatio > 35) + { + var severity = mergeRatio > 50 + ? HealthIssueSeverity.Error + : HealthIssueSeverity.Warning; + + issues.Add(new HealthIssue + { + Code = mergeRatio > 50 ? "EXCESSIVE_MERGES" : "HIGH_MERGE_RATIO", + Category = "Merges", + Severity = severity, + Title = mergeRatio > 50 ? Str.Report_ExcessiveMerges : Str.Report_HighMergeRatio, + Description = Str.Report_MergeRatioDesc(mergeRatio, analysis.MergeMetrics.TotalMerges, analysis.MergeMetrics.TotalCommits), + ImpactScore = (mergeRatio - 25) / 2 + }); + } + + // Merge fix commits + if (analysis.MergeMetrics.MergeFixCommits > 0) + { + issues.Add(new HealthIssue + { + Code = "MERGE_FIX_COMMITS", + Category = "Merges", + Severity = HealthIssueSeverity.Warning, + Title = Str.Report_MergeFixCommits, + Description = Str.Report_MergeFixDesc(analysis.MergeMetrics.MergeFixCommits), + ImpactScore = analysis.MergeMetrics.MergeFixCommits * 3, + AffectedCommits = analysis.MergeMetrics.MergeFixCommitHashes.Take(10).ToList() + }); + } + + // Branch complexity + if (analysis.BranchMetrics.Topology >= BranchTopologyType.Tangled) + { + issues.Add(new HealthIssue + { + Code = "TANGLED_BRANCHES", + Category = "Branches", + Severity = analysis.BranchMetrics.Topology == BranchTopologyType.Spaghetti + ? HealthIssueSeverity.Error + : HealthIssueSeverity.Warning, + Title = Str.Report_CrossMerges, + Description = Str.Report_CrossMergesDesc(analysis.BranchMetrics.CrossMerges), + ImpactScore = analysis.BranchMetrics.CrossMerges * 2 + }); + } + + // Stale branches + if (analysis.BranchMetrics.StaleBranches > 3) + { + issues.Add(new HealthIssue + { + Code = "STALE_BRANCHES", + Category = "Branches", + Severity = HealthIssueSeverity.Info, + Title = Str.Report_StaleBranches, + Description = Str.Report_StaleBranchesDesc(analysis.BranchMetrics.StaleBranches), + ImpactScore = analysis.BranchMetrics.StaleBranches + }); + } + + // Message quality + if (analysis.MessageDistribution.AverageScore < 50) + { + issues.Add(new HealthIssue + { + Code = "LOW_MESSAGE_QUALITY", + Category = "Messages", + Severity = HealthIssueSeverity.Error, + Title = "Low average message quality", + Description = $"Average commit message score is {analysis.MessageDistribution.AverageScore:F0}/100. " + + $"{analysis.MessageDistribution.Poor} commits have poor quality messages.", + ImpactScore = (int)(50 - analysis.MessageDistribution.AverageScore), + AffectedCommits = analysis.MessageDistribution.PoorCommitHashes.Take(20).ToList() + }); + } + else if (analysis.MessageDistribution.Poor > analysis.MessageDistribution.TotalCommits * 0.3) + { + issues.Add(new HealthIssue + { + Code = "MANY_POOR_MESSAGES", + Category = "Messages", + Severity = HealthIssueSeverity.Warning, + Title = "Many poor quality messages", + Description = $"{analysis.MessageDistribution.Poor} commits ({analysis.MessageDistribution.Poor * 100 / Math.Max(1, analysis.MessageDistribution.TotalCommits)}%) " + + "have poor quality messages (score < 50).", + ImpactScore = analysis.MessageDistribution.Poor / 2, + AffectedCommits = analysis.MessageDistribution.PoorCommitHashes.Take(20).ToList() + }); + } + + return issues.OrderByDescending(i => i.Severity).ThenByDescending(i => i.ImpactScore).ToList(); + } + + private List GenerateRecommendations( + List issues, + HistoryHealthAnalysis analysis) + { + var recommendations = new List(); + + foreach (var issue in issues.Where(i => i.Severity >= HealthIssueSeverity.Warning)) + { + // ExpectedScoreImprovement should match the issue's ImpactScore + var rec = issue.Code switch + { + "DUPLICATE_COMMITS" => new HealthRecommendation + { + Category = "Duplicates", + Title = "Squash duplicate commits", + Description = "Remove duplicate commits to clean up history", + Action = "Use interactive rebase to squash or drop duplicate commits", + Rationale = "Duplicates make history harder to understand and can cause merge conflicts", + PriorityScore = 80, + Effort = EstimatedEffort.Medium, + ExpectedScoreImprovement = issue.ImpactScore + }, + "EXCESSIVE_MERGES" or "HIGH_MERGE_RATIO" => new HealthRecommendation + { + Category = "Workflow", + Title = "Switch to rebase workflow", + Description = "Use rebase instead of merge for feature branches", + Action = "Configure git to use rebase by default: git config pull.rebase true", + Rationale = "Linear history is easier to understand and bisect", + PriorityScore = 70, + Effort = EstimatedEffort.Low, + ExpectedScoreImprovement = issue.ImpactScore + }, + "MERGE_FIX_COMMITS" => new HealthRecommendation + { + Category = "Merges", + Title = "Consolidate merge fix commits", + Description = "Squash fix commits into their parent merge", + Action = "Use interactive rebase to combine fix commits with merges", + Rationale = "Fix commits indicate problematic merges that should be cleaned up", + PriorityScore = 60, + Effort = EstimatedEffort.Medium, + ExpectedScoreImprovement = issue.ImpactScore + }, + "TANGLED_BRANCHES" => new HealthRecommendation + { + Category = "Branches", + Title = "Linearize branch structure", + Description = "Rebase feature branches onto main instead of cross-merging", + Action = "For future work: always branch from and merge to main only", + Rationale = "Cross-merges create complex dependencies and merge conflicts", + PriorityScore = 65, + Effort = EstimatedEffort.High, + ExpectedScoreImprovement = issue.ImpactScore + }, + "STALE_BRANCHES" => new HealthRecommendation + { + Category = "Branches", + Title = "Archive stale branches", + Description = "Delete or tag old branches that are no longer needed", + Action = "git branch -d for merged branches, or create archive tags", + Rationale = "Stale branches clutter the repository and can cause confusion", + PriorityScore = 30, + Effort = EstimatedEffort.Minimal, + ExpectedScoreImprovement = issue.ImpactScore + }, + "LOW_MESSAGE_QUALITY" or "MANY_POOR_MESSAGES" => new HealthRecommendation + { + Category = "Messages", + Title = "Rewrite poor commit messages", + Description = "Use AI to generate better commit messages", + Action = "Use GitCleaner's AI suggestion feature to reword commits", + Rationale = "Good commit messages are essential for maintainability", + PriorityScore = 75, + Effort = EstimatedEffort.Low, + ExpectedScoreImprovement = issue.ImpactScore + }, + _ => null + }; + + if (rec != null) + recommendations.Add(rec); + } + + return recommendations.OrderByDescending(r => r.PriorityScore).ToList(); + } + + private CleanupSuggestions GenerateCleanupSuggestions( + List issues, + HistoryHealthAnalysis analysis) + { + var automated = new List(); + var semiAutomated = new List(); + var manual = new List(); + + // Message rewriting - fully automated with existing feature + if (analysis.MessageDistribution.Poor > 0) + { + // ImpactScore for MANY_POOR_MESSAGES = Poor / 2 + // ImpactScore for LOW_MESSAGE_QUALITY = (50 - AverageScore) + var messageImpact = Math.Max( + analysis.MessageDistribution.Poor / 2, + (int)Math.Max(0, 50 - analysis.MessageDistribution.AverageScore)); + + automated.Add(new CleanupOperation + { + Id = "rewrite-messages", + Title = "Rewrite poor commit messages", + Description = $"Use AI to improve {analysis.MessageDistribution.Poor} commit messages with score < 50", + Type = CleanupType.RewordMessages, + AutomationLevel = CleanupAutomationLevel.FullyAutomated, + Effort = EstimatedEffort.Low, + Risk = RiskLevel.Low, + ExpectedScoreImprovement = messageImpact, + AffectedCommits = analysis.MessageDistribution.PoorCommitHashes.ToList() + }); + } + + // Duplicate squashing - semi-automated + // IMPORTANT: Only squash ExactTree duplicates (identical file content) + // ExactMessage duplicates have the same message but DIFFERENT code changes - squashing would lose code! + var exactTreeGroups = analysis.Duplicates.DuplicateGroups + .Where(g => g.Type == DuplicateType.ExactTree) + .ToList(); + + if (exactTreeGroups.Count > 0) + { + var exactTreeInstances = exactTreeGroups.Sum(g => g.InstanceCount - 1); + // Impact score based only on exact tree duplicates (safe to squash) + var duplicateImpact = exactTreeInstances * 5 + exactTreeGroups.Count * 2; + + semiAutomated.Add(new CleanupOperation + { + Id = "squash-duplicates", + Title = "Squash duplicate commits", + Description = $"Consolidate {exactTreeGroups.Count} duplicate commit groups " + + $"with identical content ({exactTreeInstances} redundant commits)", + Type = CleanupType.SquashDuplicates, + AutomationLevel = CleanupAutomationLevel.SemiAutomated, + Effort = EstimatedEffort.Medium, + Risk = RiskLevel.Medium, + ExpectedScoreImprovement = duplicateImpact, + AffectedCommits = exactTreeGroups + .SelectMany(g => g.CommitHashes) + .ToList(), + GitCommand = "git rebase -i HEAD~N # Mark duplicates as 'drop' or 'fixup'" + }); + } + + // Stale branch cleanup - semi-automated + if (analysis.BranchMetrics.StaleBranches > 0) + { + // ImpactScore for STALE_BRANCHES = StaleBranches + semiAutomated.Add(new CleanupOperation + { + Id = "archive-stale-branches", + Title = "Archive stale branches", + Description = $"Delete or tag {analysis.BranchMetrics.StaleBranches} stale branches", + Type = CleanupType.ArchiveBranches, + AutomationLevel = CleanupAutomationLevel.SemiAutomated, + Effort = EstimatedEffort.Minimal, + Risk = RiskLevel.None, + ExpectedScoreImprovement = analysis.BranchMetrics.StaleBranches, + GitCommand = "git branch -d # For merged branches\n" + + "git tag archive/ && git branch -D # For archiving" + }); + } + + // Merge consolidation - semi-automated (we can execute this) + if (analysis.MergeMetrics.MergeFixCommits > 0) + { + // ImpactScore for MERGE_FIX_COMMITS = MergeFixCommits * 3 + semiAutomated.Add(new CleanupOperation + { + Id = "consolidate-merges", + Title = "Consolidate merge fix commits", + Description = $"Squash {analysis.MergeMetrics.MergeFixCommits} merge fix commits into their parent merges", + Type = CleanupType.ConsolidateMerges, + AutomationLevel = CleanupAutomationLevel.SemiAutomated, + Effort = EstimatedEffort.Medium, + Risk = RiskLevel.High, + ExpectedScoreImprovement = analysis.MergeMetrics.MergeFixCommits * 3, + AffectedCommits = analysis.MergeMetrics.MergeFixCommitHashes.ToList(), + GitCommand = "git rebase -i ^ # Squash fix commits into merge" + }); + } + + // History linearization - semi-automated (we can execute this) + if (analysis.BranchMetrics.Topology >= BranchTopologyType.Tangled) + { + semiAutomated.Add(new CleanupOperation + { + Id = "linearize-history", + Title = "Linearize branch structure", + Description = "Remove merge commits and create a cleaner, linear history", + Type = CleanupType.RebaseLinearize, + AutomationLevel = CleanupAutomationLevel.SemiAutomated, + Effort = EstimatedEffort.Medium, + Risk = RiskLevel.High, + ExpectedScoreImprovement = 15, + GitCommand = "git rebase main # Alternative manual approach" + }); + } + + return new CleanupSuggestions + { + AutomatedOperations = automated, + SemiAutomatedOperations = semiAutomated, + ManualOperations = manual + }; + } + + public async Task ExportReportAsync( + HistoryHealthReport report, + ReportFormat format, + CancellationToken ct = default) + { + return format switch + { + ReportFormat.Json => ExportToJson(report), + ReportFormat.Markdown => ExportToMarkdown(report), + ReportFormat.Html => ExportToHtml(report), + ReportFormat.Console => ExportToConsole(report), + _ => throw new ArgumentOutOfRangeException(nameof(format)) + }; + } + + public async Task ExportReportToFileAsync( + HistoryHealthReport report, + ReportFormat format, + string outputPath, + CancellationToken ct = default) + { + var content = await ExportReportAsync(report, format, ct); + await File.WriteAllTextAsync(outputPath, content, ct); + } + + private string ExportToJson(HistoryHealthReport report) + { + return JsonSerializer.Serialize(report, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + + private string ExportToMarkdown(HistoryHealthReport report) + { + var sb = new StringBuilder(); + + sb.AppendLine("# Git History Health Report"); + sb.AppendLine(); + sb.AppendLine($"**Repository:** {report.RepoName}"); + sb.AppendLine($"**Branch:** {report.CurrentBranch}"); + sb.AppendLine($"**Generated:** {report.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC"); + sb.AppendLine($"**Commits Analyzed:** {report.CommitsAnalyzed}"); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + + // Overall score + var gradeIcon = report.Score.Grade.GetIcon(); + sb.AppendLine($"## Overall Health Score: {report.Score.OverallScore}/100 ({report.Score.Grade}) {gradeIcon}"); + sb.AppendLine(); + sb.AppendLine(report.Score.Grade.GetDescription()); + sb.AppendLine(); + + // Component scores + sb.AppendLine("### Component Scores"); + sb.AppendLine(); + sb.AppendLine($"| Component | Score | Status |"); + sb.AppendLine($"|-----------|-------|--------|"); + sb.AppendLine($"| Messages | {report.Score.ComponentScores.MessageScore}/100 | {GetStatusIcon(report.Score.ComponentScores.MessageScore)} |"); + sb.AppendLine($"| Merges | {report.Score.ComponentScores.MergeScore}/100 | {GetStatusIcon(report.Score.ComponentScores.MergeScore)} |"); + sb.AppendLine($"| Duplicates | {report.Score.ComponentScores.DuplicateScore}/100 | {GetStatusIcon(report.Score.ComponentScores.DuplicateScore)} |"); + sb.AppendLine($"| Branches | {report.Score.ComponentScores.BranchScore}/100 | {GetStatusIcon(report.Score.ComponentScores.BranchScore)} |"); + sb.AppendLine($"| Authorship | {report.Score.ComponentScores.AuthorshipScore}/100 | {GetStatusIcon(report.Score.ComponentScores.AuthorshipScore)} |"); + sb.AppendLine(); + + // Issues + if (report.Issues.Count > 0) + { + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine($"## Issues Found ({report.Issues.Count})"); + sb.AppendLine(); + + foreach (var issue in report.Issues) + { + var severityIcon = issue.Severity switch + { + HealthIssueSeverity.Critical => "🚨", + HealthIssueSeverity.Error => "❌", + HealthIssueSeverity.Warning => "⚠️", + _ => "ℹ️" + }; + + sb.AppendLine($"### {severityIcon} {issue.Title}"); + sb.AppendLine(); + sb.AppendLine($"**Code:** `{issue.Code}` | **Category:** {issue.Category} | **Impact:** -{issue.ImpactScore} points"); + sb.AppendLine(); + sb.AppendLine(issue.Description); + sb.AppendLine(); + + if (issue.AffectedCommits.Count > 0) + { + sb.AppendLine($"**Affected commits:** `{string.Join("`, `", issue.AffectedCommits.Take(5))}`" + + (issue.AffectedCommits.Count > 5 ? $" and {issue.AffectedCommits.Count - 5} more..." : "")); + sb.AppendLine(); + } + } + } + + // Recommendations + if (report.Recommendations.Count > 0) + { + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("## Recommendations"); + sb.AppendLine(); + + foreach (var rec in report.Recommendations) + { + sb.AppendLine($"### {rec.Title}"); + sb.AppendLine(); + sb.AppendLine($"**Priority:** {rec.PriorityScore}/100 | **Effort:** {rec.Effort} | **Expected Improvement:** +{rec.ExpectedScoreImprovement} points"); + sb.AppendLine(); + sb.AppendLine(rec.Description); + sb.AppendLine(); + sb.AppendLine($"**Action:** {rec.Action}"); + sb.AppendLine(); + } + } + + // Cleanup suggestions + if (report.CleanupSuggestions != null && report.CleanupSuggestions.TotalOperations > 0) + { + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("## Cleanup Operations"); + sb.AppendLine(); + sb.AppendLine($"**Total expected improvement:** +{report.CleanupSuggestions.TotalExpectedImprovement} points"); + sb.AppendLine(); + + if (report.CleanupSuggestions.AutomatedOperations.Count > 0) + { + sb.AppendLine("### Automated (Safe)"); + foreach (var op in report.CleanupSuggestions.AutomatedOperations) + { + sb.AppendLine($"- **{op.Title}**: {op.Description} (+{op.ExpectedScoreImprovement} points)"); + } + sb.AppendLine(); + } + + if (report.CleanupSuggestions.SemiAutomatedOperations.Count > 0) + { + sb.AppendLine("### Semi-Automated (Review Required)"); + foreach (var op in report.CleanupSuggestions.SemiAutomatedOperations) + { + sb.AppendLine($"- **{op.Title}**: {op.Description} (+{op.ExpectedScoreImprovement} points)"); + if (!string.IsNullOrEmpty(op.GitCommand)) + { + sb.AppendLine($" ```bash"); + sb.AppendLine($" {op.GitCommand}"); + sb.AppendLine($" ```"); + } + } + sb.AppendLine(); + } + + if (report.CleanupSuggestions.ManualOperations.Count > 0) + { + sb.AppendLine("### Manual (High Risk)"); + foreach (var op in report.CleanupSuggestions.ManualOperations) + { + sb.AppendLine($"- **{op.Title}**: {op.Description} (+{op.ExpectedScoreImprovement} points)"); + if (!string.IsNullOrEmpty(op.GitCommand)) + { + sb.AppendLine($" ```bash"); + sb.AppendLine($" {op.GitCommand}"); + sb.AppendLine($" ```"); + } + } + sb.AppendLine(); + } + } + + return sb.ToString(); + } + + private string ExportToHtml(HistoryHealthReport report) + { + // Simple HTML wrapper around markdown content + var markdown = ExportToMarkdown(report); + return $@" + + + Git Health Report - {report.RepoName} + + + +
{System.Web.HttpUtility.HtmlEncode(markdown)}
+ +"; + } + + private string ExportToConsole(HistoryHealthReport report) + { + var sb = new StringBuilder(); + + sb.AppendLine($"╔══════════════════════════════════════════════════════════════╗"); + sb.AppendLine($"║ GIT HISTORY HEALTH REPORT ║"); + sb.AppendLine($"╠══════════════════════════════════════════════════════════════╣"); + sb.AppendLine($"║ Repository: {report.RepoName,-48} ║"); + sb.AppendLine($"║ Branch: {report.CurrentBranch,-52} ║"); + sb.AppendLine($"║ Commits: {report.CommitsAnalyzed,-51} ║"); + sb.AppendLine($"╠══════════════════════════════════════════════════════════════╣"); + sb.AppendLine($"║ OVERALL SCORE: {report.Score.OverallScore,3}/100 Grade: {report.Score.Grade,-22} ║"); + sb.AppendLine($"╠══════════════════════════════════════════════════════════════╣"); + sb.AppendLine($"║ Components: ║"); + sb.AppendLine($"║ Messages: {report.Score.ComponentScores.MessageScore,3}/100 {GetBar(report.Score.ComponentScores.MessageScore),-30} ║"); + sb.AppendLine($"║ Merges: {report.Score.ComponentScores.MergeScore,3}/100 {GetBar(report.Score.ComponentScores.MergeScore),-30} ║"); + sb.AppendLine($"║ Duplicates: {report.Score.ComponentScores.DuplicateScore,3}/100 {GetBar(report.Score.ComponentScores.DuplicateScore),-30} ║"); + sb.AppendLine($"║ Branches: {report.Score.ComponentScores.BranchScore,3}/100 {GetBar(report.Score.ComponentScores.BranchScore),-30} ║"); + sb.AppendLine($"║ Authorship: {report.Score.ComponentScores.AuthorshipScore,3}/100 {GetBar(report.Score.ComponentScores.AuthorshipScore),-30} ║"); + sb.AppendLine($"╠══════════════════════════════════════════════════════════════╣"); + sb.AppendLine($"║ Issues: {report.CriticalIssueCount} critical, {report.ErrorCount} errors, {report.WarningCount} warnings ║"); + sb.AppendLine($"╚══════════════════════════════════════════════════════════════╝"); + + return sb.ToString(); + } + + private static string GetStatusIcon(int score) => score switch + { + >= 90 => "✅ Excellent", + >= 70 => "👍 Good", + >= 50 => "⚠️ Fair", + >= 30 => "❌ Poor", + _ => "🚨 Critical" + }; + + private static string GetBar(int score) + { + var filled = score / 5; + var empty = 20 - filled; + return $"[{new string('█', filled)}{new string('░', empty)}]"; + } +} diff --git a/Services/HistoryHealthAnalyzer.cs b/Services/HistoryHealthAnalyzer.cs new file mode 100644 index 0000000..ebba14d --- /dev/null +++ b/Services/HistoryHealthAnalyzer.cs @@ -0,0 +1,665 @@ +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; +using MarketAlly.GitCommitEditor.Resources; +using MarketAlly.LibGit2Sharp; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Analyzes git repository history health. +/// +public sealed class HistoryHealthAnalyzer : IHistoryHealthAnalyzer +{ + private readonly ICommitAnalyzer _commitAnalyzer; + private readonly HealthScoringWeights _weights; + + // Patterns that indicate merge fix commits + private static readonly string[] MergeFixPatterns = + [ + "fix merge", + "merge fix", + "resolve conflict", + "fix conflict", + "meerge", // Common typo + "megre", // Common typo + "fixed merge" + ]; + + public HistoryHealthAnalyzer(ICommitAnalyzer commitAnalyzer, HealthScoringWeights? weights = null) + { + _commitAnalyzer = commitAnalyzer; + _weights = weights ?? HealthScoringWeights.Default; + } + + public Task AnalyzeAsync( + string repoPath, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + var repo = new ManagedRepo + { + Path = repoPath, + Name = Path.GetFileName(repoPath) + }; + return AnalyzeAsync(repo, options, progress, ct); + } + + public async Task AnalyzeAsync( + ManagedRepo managedRepo, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default) + { + options ??= new HistoryAnalysisOptions(); + + using var repo = new Repository(managedRepo.Path); + + // Get commits to analyze + progress?.Report(new AnalysisProgress { CurrentStage = Str.Health_LoadingCommits, PercentComplete = 0 }); + + var commits = GetCommitsToAnalyze(repo, options).ToList(); + var totalCommits = commits.Count; + + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_LoadingCommits, + PercentComplete = 10, + TotalCommits = totalCommits + }); + + // Analyze duplicates + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_DetectingDuplicates, + PercentComplete = 20, + TotalCommits = totalCommits + }); + var duplicateMetrics = options.IncludeDuplicateDetection + ? await Task.Run(() => AnalyzeDuplicates(commits), ct) + : CreateEmptyDuplicateMetrics(totalCommits); + + ct.ThrowIfCancellationRequested(); + + // Analyze merges + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_AnalyzingMerges, + PercentComplete = 40, + TotalCommits = totalCommits + }); + var mergeMetrics = await Task.Run(() => AnalyzeMerges(commits), ct); + + ct.ThrowIfCancellationRequested(); + + // Analyze branches + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_AnalyzingBranches, + PercentComplete = 50, + TotalCommits = totalCommits + }); + var branchMetrics = options.IncludeBranchAnalysis + ? await Task.Run(() => AnalyzeBranches(repo, commits), ct) + : CreateEmptyBranchMetrics(); + + ct.ThrowIfCancellationRequested(); + + // Analyze message quality + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_AnalyzingMessages, + PercentComplete = 60, + TotalCommits = totalCommits + }); + var messageDistribution = options.IncludeMessageDistribution + ? await Task.Run(() => AnalyzeMessageQuality(commits, progress, totalCommits), ct) + : CreateEmptyMessageDistribution(totalCommits); + + ct.ThrowIfCancellationRequested(); + + // Analyze authorship + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_AnalyzingAuthorship, + PercentComplete = 90, + TotalCommits = totalCommits + }); + var authorshipMetrics = await Task.Run(() => AnalyzeAuthorship(commits), ct); + + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_Complete, + PercentComplete = 100, + TotalCommits = totalCommits, + CommitsProcessed = totalCommits + }); + + return new HistoryHealthAnalysis + { + RepoPath = managedRepo.Path, + RepoName = managedRepo.Name, + CurrentBranch = repo.Head.FriendlyName, + CommitsAnalyzed = totalCommits, + OldestCommitDate = commits.LastOrDefault()?.Author.When, + NewestCommitDate = commits.FirstOrDefault()?.Author.When, + Duplicates = duplicateMetrics, + MergeMetrics = mergeMetrics, + BranchMetrics = branchMetrics, + MessageDistribution = messageDistribution, + AuthorshipMetrics = authorshipMetrics + }; + } + + private IEnumerable GetCommitsToAnalyze(Repository repo, HistoryAnalysisOptions options) + { + var filter = new CommitFilter + { + SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time + }; + + IEnumerable commits = repo.Commits.QueryBy(filter); + + if (options.AnalyzeSince.HasValue) + { + commits = commits.Where(c => c.Author.When >= options.AnalyzeSince.Value); + } + + return commits.Take(options.EffectiveMaxCommits); + } + + private DuplicateCommitMetrics AnalyzeDuplicates(List commits) + { + var duplicateGroups = new List(); + var messageGroups = new Dictionary>(); + var treeGroups = new Dictionary>(); + + foreach (var commit in commits) + { + // Skip merge commits for duplicate detection + if (commit.Parents.Count() > 1) continue; + + var message = NormalizeMessage(commit.MessageShort); + var treeSha = commit.Tree.Sha; + + // Group by normalized message + if (!messageGroups.ContainsKey(message)) + messageGroups[message] = []; + messageGroups[message].Add(commit); + + // Group by tree SHA + if (!treeGroups.ContainsKey(treeSha)) + treeGroups[treeSha] = []; + treeGroups[treeSha].Add(commit); + } + + // Find exact tree duplicates + foreach (var group in treeGroups.Where(g => g.Value.Count > 1)) + { + duplicateGroups.Add(new DuplicateCommitGroup + { + CanonicalMessage = group.Value.First().MessageShort, + CommitHashes = group.Value.Select(c => c.Sha).ToList(), + Type = DuplicateType.ExactTree + }); + } + + // Find exact message duplicates (not already in tree duplicates) + var treeDuplicateHashes = new HashSet( + duplicateGroups.SelectMany(g => g.CommitHashes)); + + foreach (var group in messageGroups.Where(g => g.Value.Count > 1)) + { + var nonTreeDuplicates = group.Value + .Where(c => !treeDuplicateHashes.Contains(c.Sha)) + .ToList(); + + if (nonTreeDuplicates.Count > 1) + { + duplicateGroups.Add(new DuplicateCommitGroup + { + CanonicalMessage = group.Key, + CommitHashes = nonTreeDuplicates.Select(c => c.Sha).ToList(), + Type = DuplicateType.ExactMessage + }); + } + } + + var exactDuplicates = duplicateGroups + .Where(g => g.Type == DuplicateType.ExactTree) + .Sum(g => g.InstanceCount - 1); + + return new DuplicateCommitMetrics + { + TotalCommitsAnalyzed = commits.Count, + TotalDuplicateGroups = duplicateGroups.Count, + TotalDuplicateInstances = duplicateGroups.Sum(g => g.InstanceCount - 1), + ExactDuplicates = exactDuplicates, + CherryPicks = 0, // Would need patch-id comparison + FuzzyMatches = duplicateGroups.Count(g => g.Type == DuplicateType.FuzzyMessage), + DuplicateGroups = duplicateGroups + }; + } + + private MergeCommitMetrics AnalyzeMerges(List commits) + { + var mergeCommits = commits.Where(c => c.Parents.Count() > 1).ToList(); + var nonMergeCommits = commits.Where(c => c.Parents.Count() <= 1).ToList(); + + var mergeFixCommits = new List(); + var messyPatterns = new List(); + + foreach (var commit in commits) + { + var msgLower = commit.MessageShort.ToLowerInvariant(); + + foreach (var pattern in MergeFixPatterns) + { + if (msgLower.Contains(pattern)) + { + if (commit.Parents.Count() <= 1) + { + mergeFixCommits.Add(commit.Sha); + } + if (!messyPatterns.Contains(pattern)) + { + messyPatterns.Add(pattern); + } + break; + } + } + } + + // Count null merges (empty merge commits) + var nullMerges = mergeCommits.Count(m => + { + var parents = m.Parents.ToList(); + return parents.Count == 2 && m.Tree.Sha == parents[0].Tree.Sha; + }); + + return new MergeCommitMetrics + { + TotalCommits = commits.Count, + TotalMerges = mergeCommits.Count, + TrivialMerges = mergeCommits.Count - nullMerges, + ConflictMerges = 0, // Hard to detect reliably + MergeFixCommits = mergeFixCommits.Count, + NullMerges = nullMerges, + AverageMergeComplexity = 0, // Would need tree diff + MessyMergePatterns = messyPatterns, + MergeFixCommitHashes = mergeFixCommits + }; + } + + private BranchComplexityMetrics AnalyzeBranches(Repository repo, List commits) + { + var branches = repo.Branches.Where(b => !b.IsRemote).ToList(); + var remoteBranches = repo.Branches.Where(b => b.IsRemote).ToList(); + + var now = DateTimeOffset.UtcNow; + var staleDays = 30; + + var staleBranches = new List(); + var activeBranches = 0; + + foreach (var branch in branches) + { + if (branch.Tip == null) continue; + + var age = now - branch.Tip.Author.When; + if (age.TotalDays > staleDays) + { + staleBranches.Add(branch.FriendlyName); + } + else + { + activeBranches++; + } + } + + // Count merge commits to determine cross-merges + var mergeCommits = commits.Where(c => c.Parents.Count() > 1).ToList(); + var mainBranchNames = new[] { "main", "master", "develop", "dev" }; + + // Simplified cross-merge detection + var crossMerges = 0; + foreach (var merge in mergeCommits) + { + var msg = merge.MessageShort.ToLowerInvariant(); + // If merge message doesn't mention main branches, it's likely a cross-merge + if (!mainBranchNames.Any(b => msg.Contains(b))) + { + crossMerges++; + } + } + + // Determine topology + var mergeRatio = commits.Count > 0 ? (double)mergeCommits.Count / commits.Count : 0; + var crossMergeRatio = mergeCommits.Count > 0 ? (double)crossMerges / mergeCommits.Count : 0; + + var topology = DetermineTopology(mergeRatio, crossMergeRatio, staleBranches.Count); + + return new BranchComplexityMetrics + { + TotalBranches = branches.Count, // Only count local branches + ActiveBranches = activeBranches, + StaleBranches = staleBranches.Count, + CrossMerges = crossMerges, + AverageBranchAge = 0, // Would need more calculation + AverageBranchLength = 0, + LongLivedBranches = 0, + Topology = topology, + StaleBranchNames = staleBranches + }; + } + + private BranchTopologyType DetermineTopology(double mergeRatio, double crossMergeRatio, int staleBranches) + { + if (mergeRatio < 0.1) + return BranchTopologyType.Linear; + + if (crossMergeRatio > 0.5 || (mergeRatio > 0.5 && staleBranches > 5)) + return BranchTopologyType.Spaghetti; + + if (crossMergeRatio > 0.2 || mergeRatio > 0.4) + return BranchTopologyType.Tangled; + + if (mergeRatio > 0.2) + return BranchTopologyType.GitFlow; + + return BranchTopologyType.Balanced; + } + + private MessageQualityDistribution AnalyzeMessageQuality( + List commits, + IProgress? progress, + int totalCommits) + { + var scores = new List<(string Hash, int Score, DateTimeOffset Date)>(); + var poorCommits = new List(); + var processed = 0; + + foreach (var commit in commits) + { + // Skip merge commits for message quality + if (commit.Parents.Count() > 1) continue; + + var analysis = _commitAnalyzer.Analyze(commit.Message); + scores.Add((commit.Sha, analysis.OverallScore, commit.Author.When)); + + if (analysis.OverallScore < 50) + { + poorCommits.Add(commit.Sha); + } + + processed++; + if (processed % 100 == 0) + { + progress?.Report(new AnalysisProgress + { + CurrentStage = Str.Health_AnalyzingMessages, + PercentComplete = 60 + (int)(30.0 * processed / totalCommits), + CommitsProcessed = processed, + TotalCommits = totalCommits + }); + } + } + + if (scores.Count == 0) + { + return CreateEmptyMessageDistribution(totalCommits); + } + + var sortedScores = scores.Select(s => s.Score).OrderBy(s => s).ToList(); + var avg = sortedScores.Average(); + var median = sortedScores[sortedScores.Count / 2]; + + // Calculate standard deviation + var sumSquaredDiff = sortedScores.Sum(s => Math.Pow(s - avg, 2)); + var stdDev = Math.Sqrt(sumSquaredDiff / sortedScores.Count); + + // Determine trend (compare first half vs second half) + var midpoint = scores.Count / 2; + var olderAvg = scores.Skip(midpoint).Average(s => s.Score); + var newerAvg = scores.Take(midpoint).Average(s => s.Score); + var trend = newerAvg > olderAvg + 5 ? TrendDirection.Improving + : newerAvg < olderAvg - 5 ? TrendDirection.Declining + : TrendDirection.Stable; + + return new MessageQualityDistribution + { + TotalCommits = scores.Count, + Excellent = scores.Count(s => s.Score >= 90), + Good = scores.Count(s => s.Score >= 70 && s.Score < 90), + Fair = scores.Count(s => s.Score >= 50 && s.Score < 70), + Poor = scores.Count(s => s.Score < 50), + AverageScore = avg, + MedianScore = median, + StandardDeviation = stdDev, + Trend = trend, + Clusters = [], // Could add cluster detection + PoorCommitHashes = poorCommits + }; + } + + private AuthorshipMetrics AnalyzeAuthorship(List commits) + { + // Track running totals for calculating averages + var authorData = new Dictionary(); + var emailToCanonical = new Dictionary(); // Maps raw email to canonical key + var nameToCanonical = new Dictionary(); // Maps normalized name to canonical key + var missingEmail = 0; + var invalidEmail = 0; + var botCommits = 0; + + var botPatterns = new[] { "[bot]", "dependabot", "renovate", "github-actions", "noreply" }; + + foreach (var commit in commits) + { + var email = commit.Author.Email ?? ""; + var name = commit.Author.Name ?? "Unknown"; + var normalizedEmail = NormalizeEmail(email); + var normalizedName = NormalizeName(name); + + if (string.IsNullOrWhiteSpace(email)) + { + missingEmail++; + } + else if (!email.Contains('@')) + { + invalidEmail++; + } + + var isMerge = commit.Parents.Count() > 1; + var isBot = botPatterns.Any(p => + name.Contains(p, StringComparison.OrdinalIgnoreCase) || + email.Contains(p, StringComparison.OrdinalIgnoreCase)); + + if (isBot) botCommits++; + + // Analyze message quality for this commit + var quality = _commitAnalyzer.Analyze(commit.Message); + var qualityScore = quality.OverallScore; + + // Find or create canonical key for this author + var canonicalKey = FindOrCreateCanonicalKey( + normalizedEmail, normalizedName, name, email, + emailToCanonical, nameToCanonical, authorData); + + if (!authorData.TryGetValue(canonicalKey, out var data)) + { + data = (name, email, 0, 0, 0); + } + + // Update running totals + authorData[canonicalKey] = ( + data.Name, + data.Email, + data.CommitCount + 1, + data.TotalQuality + qualityScore, + data.MergeCount + (isMerge ? 1 : 0) + ); + } + + // Convert to AuthorStats with calculated averages + var authorStats = authorData.ToDictionary( + kvp => kvp.Key, + kvp => new AuthorStats + { + Name = kvp.Value.Name, + Email = kvp.Value.Email, + CommitCount = kvp.Value.CommitCount, + AverageMessageQuality = kvp.Value.CommitCount > 0 + ? kvp.Value.TotalQuality / kvp.Value.CommitCount + : 0, + MergeCommitCount = kvp.Value.MergeCount + }); + + return new AuthorshipMetrics + { + TotalAuthors = authorStats.Count, + TotalCommits = commits.Count, + MissingEmailCount = missingEmail, + InvalidEmailCount = invalidEmail, + BotCommits = botCommits, + AuthorBreakdown = authorStats + }; + } + + private static string FindOrCreateCanonicalKey( + string normalizedEmail, + string normalizedName, + string rawName, + string rawEmail, + Dictionary emailToCanonical, + Dictionary nameToCanonical, + Dictionary authorData) + { + // First, check if we've seen this exact email before + if (!string.IsNullOrEmpty(normalizedEmail) && emailToCanonical.TryGetValue(normalizedEmail, out var existingKey)) + { + return existingKey; + } + + // Next, check if we've seen this name before (for matching when emails differ) + if (!string.IsNullOrEmpty(normalizedName) && nameToCanonical.TryGetValue(normalizedName, out existingKey)) + { + // Also map this email to the same canonical key + if (!string.IsNullOrEmpty(normalizedEmail)) + { + emailToCanonical[normalizedEmail] = existingKey; + } + return existingKey; + } + + // Create new canonical key - prefer email if valid, otherwise use name + var canonicalKey = !string.IsNullOrEmpty(normalizedEmail) ? normalizedEmail : normalizedName; + + // Register mappings + if (!string.IsNullOrEmpty(normalizedEmail)) + { + emailToCanonical[normalizedEmail] = canonicalKey; + } + if (!string.IsNullOrEmpty(normalizedName)) + { + nameToCanonical[normalizedName] = canonicalKey; + } + + return canonicalKey; + } + + private static string NormalizeEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) return ""; + + email = email.ToLowerInvariant().Trim(); + + // Remove + aliases (e.g., john+test@gmail.com -> john@gmail.com) + var atIndex = email.IndexOf('@'); + if (atIndex > 0) + { + var plusIndex = email.IndexOf('+'); + if (plusIndex > 0 && plusIndex < atIndex) + { + email = email[..plusIndex] + email[atIndex..]; + } + } + + // Normalize common noreply patterns + if (email.Contains("noreply") || email.Contains("no-reply")) + { + // Extract username from GitHub noreply format: 12345678+username@users.noreply.github.com + var match = System.Text.RegularExpressions.Regex.Match(email, @"\d+\+([^@]+)@users\.noreply\.github\.com"); + if (match.Success) + { + return match.Groups[1].Value + "@github"; + } + } + + return email; + } + + private static string NormalizeName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return ""; + + // Lowercase and trim + name = name.ToLowerInvariant().Trim(); + + // Remove common suffixes/prefixes + name = name.Replace("[bot]", "").Trim(); + + // Normalize whitespace + name = System.Text.RegularExpressions.Regex.Replace(name, @"\s+", " "); + + return name; + } + + private static string NormalizeMessage(string message) + { + return message + .ToLowerInvariant() + .Trim() + .Replace("\r", "") + .Replace("\n", " "); + } + + private static DuplicateCommitMetrics CreateEmptyDuplicateMetrics(int totalCommits) => new() + { + TotalCommitsAnalyzed = totalCommits, + TotalDuplicateGroups = 0, + TotalDuplicateInstances = 0, + ExactDuplicates = 0, + CherryPicks = 0, + FuzzyMatches = 0, + DuplicateGroups = [] + }; + + private static BranchComplexityMetrics CreateEmptyBranchMetrics() => new() + { + TotalBranches = 0, + ActiveBranches = 0, + StaleBranches = 0, + CrossMerges = 0, + AverageBranchAge = 0, + AverageBranchLength = 0, + LongLivedBranches = 0, + Topology = BranchTopologyType.Linear, + StaleBranchNames = [] + }; + + private static MessageQualityDistribution CreateEmptyMessageDistribution(int totalCommits) => new() + { + TotalCommits = totalCommits, + Excellent = 0, + Good = 0, + Fair = 0, + Poor = 0, + AverageScore = 0, + MedianScore = 0, + StandardDeviation = 0, + Trend = TrendDirection.Stable, + Clusters = [], + PoorCommitHashes = [] + }; +} diff --git a/Services/IApiKeyProvider.cs b/Services/IApiKeyProvider.cs new file mode 100755 index 0000000..770de30 --- /dev/null +++ b/Services/IApiKeyProvider.cs @@ -0,0 +1,36 @@ +using MarketAlly.AIPlugin; +using MarketAlly.AIPlugin.Conversation; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Provides access to API keys for AI providers. +/// Implement this interface in platform-specific code (e.g., using SecureStorage in MAUI). +/// +public interface IApiKeyProvider +{ + /// + /// Gets the API key for the specified provider, or null if not configured. + /// + string? GetApiKey(AIProvider provider); + + /// + /// Checks if an API key is configured for the specified provider. + /// + bool HasApiKey(AIProvider provider); + + /// + /// Sets the API key for the specified provider. + /// + Task SetApiKeyAsync(AIProvider provider, string apiKey); + + /// + /// Removes the API key for the specified provider. + /// + Task RemoveApiKeyAsync(AIProvider provider); + + /// + /// Gets all providers that have API keys configured. + /// + IReadOnlyList GetConfiguredProviders(); +} diff --git a/Services/ICleanupExecutor.cs b/Services/ICleanupExecutor.cs new file mode 100644 index 0000000..763726b --- /dev/null +++ b/Services/ICleanupExecutor.cs @@ -0,0 +1,124 @@ +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Executes cleanup operations on git repositories. +/// +public interface ICleanupExecutor +{ + /// + /// Executes a single cleanup operation. + /// + Task ExecuteAsync( + ManagedRepo repo, + CleanupOperation operation, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Executes multiple cleanup operations in sequence. + /// + Task ExecuteBatchAsync( + ManagedRepo repo, + IEnumerable operations, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Previews what a cleanup operation will do without executing it. + /// + Task PreviewAsync( + ManagedRepo repo, + CleanupOperation operation, + CancellationToken ct = default); + + /// + /// Creates a backup branch before cleanup operations. + /// + Task CreateBackupBranchAsync( + ManagedRepo repo, + string? branchName = null, + CancellationToken ct = default); +} + +/// +/// Options for cleanup execution. +/// +public sealed record CleanupExecutionOptions +{ + /// + /// Create a backup branch before making changes. + /// + public bool CreateBackup { get; init; } = true; + + /// + /// Custom backup branch name. Auto-generated if null. + /// + public string? BackupBranchName { get; init; } + + /// + /// Allow operations on pushed commits (requires force push). + /// + public bool AllowPushedCommits { get; init; } = false; + + /// + /// Automatically force push after rewriting history. + /// + public bool AutoForcePush { get; init; } = false; + + /// + /// Use AI to generate improved commit messages. + /// + public bool UseAiForMessages { get; init; } = true; +} + +/// +/// Result of executing a single cleanup operation. +/// +public sealed record CleanupExecutionResult +{ + public required string OperationId { get; init; } + public required CleanupType Type { get; init; } + public bool Success { get; init; } + public string? ErrorMessage { get; init; } + public int CommitsModified { get; init; } + public int CommitsRemoved { get; init; } + public string? BackupBranch { get; init; } + public bool RequiresForcePush { get; init; } + public IReadOnlyList ModifiedCommitHashes { get; init; } = []; + public IReadOnlyList NewCommitHashes { get; init; } = []; + public TimeSpan Duration { get; init; } +} + +/// +/// Result of executing multiple cleanup operations. +/// +public sealed class BatchCleanupResult +{ + public int TotalOperations { get; init; } + public int Successful { get; init; } + public int Failed { get; init; } + public int Skipped { get; init; } + public string? BackupBranch { get; init; } + public bool RequiresForcePush { get; init; } + public IReadOnlyList Results { get; init; } = []; + public TimeSpan TotalDuration { get; init; } + + public bool AllSucceeded => Failed == 0 && Skipped == 0; +} + +/// +/// Progress information for cleanup operations. +/// +public sealed class CleanupProgress +{ + public required string CurrentOperation { get; init; } + public int CurrentIndex { get; init; } + public int TotalOperations { get; init; } + public int PercentComplete { get; init; } + public string? CurrentCommit { get; init; } +} diff --git a/Services/ICommitAnalysisService.cs b/Services/ICommitAnalysisService.cs new file mode 100644 index 0000000..18f354f --- /dev/null +++ b/Services/ICommitAnalysisService.cs @@ -0,0 +1,18 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Provides commit analysis functionality. +/// +public interface ICommitAnalysisService +{ + Task> AnalyzeAllReposAsync( + bool onlyNeedsImprovement = true, + IProgress<(string Repo, int Processed)>? progress = null, + CancellationToken ct = default); + + IEnumerable AnalyzeRepo(ManagedRepo repo); + CommitAnalysis AnalyzeCommit(string repoPath, string commitHash); + Task UpdateRepoAnalysisAsync(ManagedRepo repo, int totalCommits, int commitsNeedingImprovement, CancellationToken ct = default); +} diff --git a/Services/ICommitAnalyzer.cs b/Services/ICommitAnalyzer.cs new file mode 100644 index 0000000..e4f5b78 --- /dev/null +++ b/Services/ICommitAnalyzer.cs @@ -0,0 +1,33 @@ +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Options; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Simple commit message analyzer for health analysis. +/// +public interface ICommitAnalyzer +{ + /// + /// Analyzes a commit message and returns quality information. + /// + MessageQualityScore Analyze(string message); +} + +/// +/// Default implementation using the existing analyzer. +/// +public sealed class CommitAnalyzer : ICommitAnalyzer +{ + private readonly ICommitMessageAnalyzer _analyzer; + + public CommitAnalyzer(CommitMessageRules? rules = null) + { + _analyzer = new CommitMessageAnalyzer(rules ?? new CommitMessageRules()); + } + + public MessageQualityScore Analyze(string message) + { + return _analyzer.Analyze(message); + } +} diff --git a/Services/ICommitMessageAnalyzer.cs b/Services/ICommitMessageAnalyzer.cs new file mode 100644 index 0000000..5dcb0df --- /dev/null +++ b/Services/ICommitMessageAnalyzer.cs @@ -0,0 +1,14 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +public interface ICommitMessageAnalyzer +{ + MessageQualityScore Analyze(string message); + + /// + /// Analyzes a commit message with context about the actual changes. + /// This allows detection of vague messages that don't describe significant changes. + /// + MessageQualityScore Analyze(string message, CommitContext context); +} diff --git a/Services/ICommitRewriteService.cs b/Services/ICommitRewriteService.cs new file mode 100644 index 0000000..da15c30 --- /dev/null +++ b/Services/ICommitRewriteService.cs @@ -0,0 +1,48 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Handles commit message rewriting operations. +/// +public interface ICommitRewriteService +{ + IReadOnlyList History { get; } + + IReadOnlyList PreviewChanges(IEnumerable analyses); + + Task ApplyChangesAsync( + IEnumerable operations, + bool dryRun = true, + IProgress<(int Processed, int Total)>? progress = null, + CancellationToken ct = default); + + Task ApplyChangeAsync(CommitAnalysis analysis, CancellationToken ct = default); + + bool UndoCommitAmend(string repoPath, string originalCommitHash); + + /// + /// Gets safety information for a batch rewrite operation. + /// Checks for uncommitted changes, pushed commits, and remote tracking status. + /// + /// The repository path. + /// The commits to be rewritten. + /// Safety information about the proposed operation. + RewriteSafetyInfo GetRewriteSafetyInfo(string repoPath, IEnumerable commits); + + /// + /// Executes a batch rewrite operation with full safety checks and backup creation. + /// + /// The repository path. + /// The commits to rewrite (must have SuggestedMessage populated). + /// Whether to create a backup branch before rewriting. + /// Progress reporter. + /// Cancellation token. + /// Result of the batch rewrite operation. + Task ExecuteBatchRewriteAsync( + string repoPath, + IEnumerable commits, + bool createBackup = true, + IProgress<(int Current, int Total, string CommitHash)>? progress = null, + CancellationToken ct = default); +} diff --git a/Services/ICostTrackingService.cs b/Services/ICostTrackingService.cs new file mode 100644 index 0000000..9c920eb --- /dev/null +++ b/Services/ICostTrackingService.cs @@ -0,0 +1,110 @@ +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Tracks AI operation costs for the session +/// +public interface ICostTrackingService +{ + /// + /// Total cost for the current session + /// + decimal SessionCost { get; } + + /// + /// Total cost across all sessions (lifetime) + /// + decimal LifetimeCost { get; } + + /// + /// Total input tokens used this session + /// + int TotalInputTokens { get; } + + /// + /// Total output tokens used this session + /// + int TotalOutputTokens { get; } + + /// + /// Number of AI operations performed this session + /// + int OperationCount { get; } + + /// + /// Number of AI operations performed across all sessions (lifetime) + /// + int LifetimeOperationCount { get; } + + /// + /// Records an AI operation's cost + /// + /// Type of operation (e.g., "CommitSuggestion", "GitDiagnosis") + /// Input tokens used + /// Output tokens used + /// Calculated cost + void RecordOperation(string operationType, int inputTokens, int outputTokens, decimal cost); + + /// + /// Gets the cost breakdown by operation type + /// + IReadOnlyDictionary GetCostBreakdown(); + + /// + /// Resets session tracking (keeps lifetime totals) + /// + void ResetSession(); + + /// + /// Resets all tracking including lifetime totals + /// + void ResetAll(); + + /// + /// Saves the current state to persistent storage + /// + void SaveState(); + + /// + /// Loads state from persistent storage + /// + void LoadState(); + + /// + /// Event raised when costs are updated + /// + event EventHandler? CostUpdated; +} + +/// +/// Summary of costs for a specific operation type +/// +public class OperationCostSummary +{ + public string OperationType { get; set; } = string.Empty; + public int Count { get; set; } + public int TotalInputTokens { get; set; } + public int TotalOutputTokens { get; set; } + public decimal TotalCost { get; set; } + public decimal AverageCost => Count > 0 ? TotalCost / Count : 0; +} + +/// +/// Event args for cost updates +/// +public class CostUpdatedEventArgs : EventArgs +{ + public string OperationType { get; } + public decimal OperationCost { get; } + public decimal SessionTotal { get; } + public int InputTokens { get; } + public int OutputTokens { get; } + + public CostUpdatedEventArgs(string operationType, decimal operationCost, decimal sessionTotal, int inputTokens, int outputTokens) + { + OperationType = operationType; + OperationCost = operationCost; + SessionTotal = sessionTotal; + InputTokens = inputTokens; + OutputTokens = outputTokens; + } +} diff --git a/Services/IGitMessageImproverService.cs b/Services/IGitMessageImproverService.cs new file mode 100755 index 0000000..e55c75e --- /dev/null +++ b/Services/IGitMessageImproverService.cs @@ -0,0 +1,25 @@ +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Unified facade for all git commit message improvement operations. +/// Composes smaller, focused interfaces for consumers who need full functionality. +/// +public interface IGitMessageImproverService : + IRepositoryManager, + ICommitAnalysisService, + ISuggestionService, + ICommitRewriteService, + IGitPushService, + IHistoryHealthService, + IDisposable +{ + /// + /// Load saved state from disk (repos, history, etc.) + /// + Task LoadStateAsync(CancellationToken ct = default); + + /// + /// Generates a summary report of all repositories and recent operations. + /// + string GenerateSummaryReport(); +} diff --git a/Services/IGitOperationsService.cs b/Services/IGitOperationsService.cs new file mode 100755 index 0000000..e673f1b --- /dev/null +++ b/Services/IGitOperationsService.cs @@ -0,0 +1,79 @@ +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.LibGit2Sharp; + +namespace MarketAlly.GitCommitEditor.Services; + +public interface IGitOperationsService : IDisposable +{ + IEnumerable DiscoverRepositories(string rootPath, int maxDepth = 3); + ManagedRepo CreateManagedRepo(string repoPath); + IEnumerable GetBranches(string repoPath); + IEnumerable AnalyzeCommits( + ManagedRepo managedRepo, + ICommitMessageAnalyzer analyzer, + int maxCommits = 100, + DateTimeOffset? since = null, + string[]? excludeAuthors = null); + RewriteOperation AmendLatestCommit(ManagedRepo managedRepo, string newMessage); + RewriteOperation RewordOlderCommit(ManagedRepo managedRepo, string commitHash, string newMessage); + + /// + /// Rewords multiple commits in a single pass through history. + /// This is more efficient and reliable than multiple individual RewordOlderCommit calls, + /// because it handles the hash changes that occur when rewriting commits. + /// + /// The repository. + /// Dictionary mapping original commit hash to new message. + /// List of rewrite operations with results. + List RewordMultipleCommits(ManagedRepo managedRepo, Dictionary rewrites); + + /// + /// Undoes a commit amend by resetting to the original commit hash from the reflog. + /// + bool UndoCommitAmend(string repoPath, string originalCommitHash); + + /// + /// Checks if a commit has been pushed to the remote tracking branch. + /// + bool IsCommitPushed(string repoPath, string commitHash); + + /// + /// Gets tracking information for the current branch. + /// + TrackingInfo GetTrackingInfo(string repoPath); + + /// + /// Force pushes the current branch to the remote. + /// + GitPushResult ForcePush(string repoPath, PushOptions? options = null); + + /// + /// Regular push (non-force) to the remote. + /// + GitPushResult Push(string repoPath, PushOptions? options = null); + + /// + /// Gets all backup branches (matching backup/* pattern). + /// + IEnumerable GetBackupBranches(string repoPath); + + /// + /// Deletes a local branch. + /// + bool DeleteBranch(string repoPath, string branchName); + + /// + /// Invalidates the cached Repository for the given path. + /// Call after operations that modify git history to ensure fresh state on next access. + /// + void InvalidateCache(string path); +} + +/// +/// Information about a backup branch. +/// +public record BackupBranchInfo( + string Name, + string FullName, + DateTimeOffset? CreatedAt, + string? LastCommitSha); diff --git a/Services/IGitPushService.cs b/Services/IGitPushService.cs new file mode 100644 index 0000000..69731fe --- /dev/null +++ b/Services/IGitPushService.cs @@ -0,0 +1,14 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Handles git push operations and remote tracking. +/// +public interface IGitPushService +{ + bool IsCommitPushed(string repoPath, string commitHash); + TrackingInfo GetTrackingInfo(string repoPath); + GitPushResult Push(string repoPath); + GitPushResult ForcePush(string repoPath); +} diff --git a/Services/IHealthReportGenerator.cs b/Services/IHealthReportGenerator.cs new file mode 100644 index 0000000..846dce8 --- /dev/null +++ b/Services/IHealthReportGenerator.cs @@ -0,0 +1,31 @@ +using MarketAlly.GitCommitEditor.Models.HistoryHealth; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Service for generating health reports from analysis results. +/// +public interface IHealthReportGenerator +{ + /// + /// Generates a comprehensive health report from analysis results. + /// + HistoryHealthReport GenerateReport(HistoryHealthAnalysis analysis); + + /// + /// Exports a report to the specified format. + /// + Task ExportReportAsync( + HistoryHealthReport report, + ReportFormat format, + CancellationToken ct = default); + + /// + /// Exports a report to a file. + /// + Task ExportReportToFileAsync( + HistoryHealthReport report, + ReportFormat format, + string outputPath, + CancellationToken ct = default); +} diff --git a/Services/IHistoryHealthAnalyzer.cs b/Services/IHistoryHealthAnalyzer.cs new file mode 100644 index 0000000..0fef321 --- /dev/null +++ b/Services/IHistoryHealthAnalyzer.cs @@ -0,0 +1,33 @@ +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Service for analyzing git repository history health. +/// +public interface IHistoryHealthAnalyzer +{ + /// + /// Performs a comprehensive health analysis of a repository. + /// + /// Path to the repository. + /// Analysis options. + /// Progress reporter. + /// Cancellation token. + /// Analysis results. + Task AnalyzeAsync( + string repoPath, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Performs a comprehensive health analysis of a managed repository. + /// + Task AnalyzeAsync( + ManagedRepo repo, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); +} diff --git a/Services/IHistoryHealthService.cs b/Services/IHistoryHealthService.cs new file mode 100644 index 0000000..7d2ad09 --- /dev/null +++ b/Services/IHistoryHealthService.cs @@ -0,0 +1,78 @@ +using MarketAlly.GitCommitEditor.Models; +using MarketAlly.GitCommitEditor.Models.HistoryHealth; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Service for analyzing and reporting on git repository history health. +/// +public interface IHistoryHealthService +{ + /// + /// Analyzes repository history health and generates a comprehensive report. + /// + /// Path to the repository. + /// Analysis options. + /// Progress reporter. + /// Cancellation token. + /// Complete health report with scores, issues, and recommendations. + Task AnalyzeHistoryHealthAsync( + string repoPath, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Analyzes history health for a managed repository. + /// + Task AnalyzeHistoryHealthAsync( + ManagedRepo repo, + HistoryAnalysisOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Exports a health report to the specified format. + /// + Task ExportHealthReportAsync( + HistoryHealthReport report, + ReportFormat format, + CancellationToken ct = default); + + /// + /// Exports a health report to a file. + /// + Task ExportHealthReportToFileAsync( + HistoryHealthReport report, + ReportFormat format, + string outputPath, + CancellationToken ct = default); + + /// + /// Executes a single cleanup operation. + /// + Task ExecuteCleanupAsync( + ManagedRepo repo, + CleanupOperation operation, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Executes all cleanup operations from a report. + /// + Task ExecuteAllCleanupsAsync( + ManagedRepo repo, + CleanupSuggestions suggestions, + CleanupExecutionOptions? options = null, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Creates a backup branch before cleanup operations. + /// + Task CreateBackupBranchAsync( + ManagedRepo repo, + string? branchName = null, + CancellationToken ct = default); +} diff --git a/Services/IModelProviderService.cs b/Services/IModelProviderService.cs new file mode 100755 index 0000000..977d83e --- /dev/null +++ b/Services/IModelProviderService.cs @@ -0,0 +1,157 @@ +using MarketAlly.AIPlugin; +using MarketAlly.AIPlugin.Agentic; +using MarketAlly.AIPlugin.Agentic.Models; +using MarketAlly.AIPlugin.Conversation; +using MarketAlly.GitCommitEditor.Options; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Service for managing AI model providers dynamically based on configured API keys. +/// Only shows providers that the user has actually configured. +/// +public interface IModelProviderService +{ + /// + /// Gets the providers that have API keys configured. + /// + IReadOnlyList AvailableProviders { get; } + + /// + /// Gets models for a provider that support function calling. + /// + IReadOnlyList GetModelsForProvider(string provider); + + /// + /// Gets models with display information for a provider. + /// + IReadOnlyList GetModelsWithInfoForProvider(string provider); + + /// + /// Gets model info by ID. + /// + ModelDisplayInfo? GetModelInfo(string modelId); + + /// + /// Gets the API key for a provider. + /// + string? GetApiKey(AIProvider provider); + + /// + /// Checks if a provider has an API key configured. + /// + bool HasApiKey(AIProvider provider); + + /// + /// Refreshes the available providers based on current API key configuration. + /// Call this after adding or removing an API key. + /// + void RefreshProviders(); +} + +/// +/// Implementation of IModelProviderService that uses IApiKeyProvider to determine available providers. +/// Uses the static ModelLibrary.Default for efficient querying. +/// +public class ModelProviderService : IModelProviderService +{ + private readonly IApiKeyProvider _apiKeyProvider; + + public ModelProviderService(IApiKeyProvider apiKeyProvider) + { + _apiKeyProvider = apiKeyProvider; + } + + public IReadOnlyList AvailableProviders => + ModelLibrary.Default.GetModelsWithFunctionCalling() + .Where(m => m.Type != ModelType.Legacy) + .Select(m => m.Provider.ToString()) + .Distinct() + .OrderBy(p => p) + .ToList(); + + public void RefreshProviders() + { + // No-op - static library doesn't need refreshing + // API key changes are handled separately by IApiKeyProvider + } + + public IReadOnlyList GetModelsForProvider(string provider) + { + var aiProvider = ParseProvider(provider); + var models = ModelLibrary.Default.GetModelsByProvider(aiProvider) + .Where(m => m.SupportsFunctionCalling && m.Type != ModelType.Legacy) + .OrderByDescending(m => m.Tier) + .ThenBy(m => m.DisplayName) + .Select(m => m.ModelId) + .ToList(); + + return models.Count > 0 ? models : new[] { ModelConstants.Claude.Sonnet4 }; + } + + public IReadOnlyList GetModelsWithInfoForProvider(string provider) + { + var aiProvider = ParseProvider(provider); + var models = ModelLibrary.Default.GetModelsByProvider(aiProvider) + .Where(m => m.SupportsFunctionCalling && m.Type != ModelType.Legacy) + .OrderByDescending(m => m.Tier) + .ThenBy(m => m.DisplayName) + .Select(m => new ModelDisplayInfo + { + ModelId = m.ModelId, + DisplayName = m.DisplayName, + Tier = m.Tier.ToString(), + InputCostPer1MTokens = m.InputCostPer1MTokens, + OutputCostPer1MTokens = m.OutputCostPer1MTokens, + Speed = m.Speed.ToString() + }) + .ToList(); + + return models.Count > 0 ? models : GetDefaultModelInfo(); + } + + public ModelDisplayInfo? GetModelInfo(string modelId) + { + var model = ModelLibrary.Default.GetModel(modelId); + if (model == null) return null; + + return new ModelDisplayInfo + { + ModelId = model.ModelId, + DisplayName = model.DisplayName, + Tier = model.Tier.ToString(), + InputCostPer1MTokens = model.InputCostPer1MTokens, + OutputCostPer1MTokens = model.OutputCostPer1MTokens, + Speed = model.Speed.ToString() + }; + } + + public string? GetApiKey(AIProvider provider) => _apiKeyProvider.GetApiKey(provider); + + public bool HasApiKey(AIProvider provider) => _apiKeyProvider.HasApiKey(provider); + + private static IReadOnlyList GetDefaultModelInfo() => new[] + { + new ModelDisplayInfo + { + ModelId = ModelConstants.Claude.Sonnet4, + DisplayName = "Claude Sonnet 4", + Tier = "Balanced", + InputCostPer1MTokens = 3.00m, + OutputCostPer1MTokens = 15.00m, + Speed = "Fast" + } + }; + + private static AIProvider ParseProvider(string? provider) + { + return provider?.ToLowerInvariant() switch + { + "claude" or "anthropic" => AIProvider.Claude, + "openai" or "gpt" => AIProvider.OpenAI, + "gemini" or "google" => AIProvider.Gemini, + "qwen" or "alibaba" => AIProvider.Qwen, + _ => AIProvider.Claude + }; + } +} diff --git a/Services/IRepositoryManager.cs b/Services/IRepositoryManager.cs new file mode 100644 index 0000000..aae5e30 --- /dev/null +++ b/Services/IRepositoryManager.cs @@ -0,0 +1,39 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Manages repository discovery and registration. +/// +public interface IRepositoryManager +{ + IReadOnlyList Repos { get; } + + Task> ScanAndRegisterReposAsync(CancellationToken ct = default); + Task RegisterRepoAsync(string repoPath); + Task UnregisterRepoAsync(string repoIdOrPath); + IEnumerable GetBranches(string repoPath); + + /// + /// Checkout a branch in the specified repository. + /// + /// The repository to switch branches in. + /// The name of the branch to checkout. + /// True if checkout succeeded, false otherwise. + Task CheckoutBranchAsync(ManagedRepo repo, string branchName); + + /// + /// Gets all backup branches in the specified repository. + /// + IEnumerable GetBackupBranches(string repoPath); + + /// + /// Deletes a local branch. + /// + bool DeleteBranch(string repoPath, string branchName); + + /// + /// Deletes all backup branches in the specified repository. + /// + int DeleteAllBackupBranches(string repoPath); +} diff --git a/Services/IStateRepository.cs b/Services/IStateRepository.cs new file mode 100644 index 0000000..79cf715 --- /dev/null +++ b/Services/IStateRepository.cs @@ -0,0 +1,9 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +public interface IStateRepository +{ + Task LoadAsync(CancellationToken ct = default); + Task SaveAsync(ImproverState state, CancellationToken ct = default); +} diff --git a/Services/ISuggestionService.cs b/Services/ISuggestionService.cs new file mode 100644 index 0000000..4e8cb6a --- /dev/null +++ b/Services/ISuggestionService.cs @@ -0,0 +1,24 @@ +using MarketAlly.GitCommitEditor.Models; + +namespace MarketAlly.GitCommitEditor.Services; + +/// +/// Provides AI-powered suggestion generation for commit messages. +/// +public interface ISuggestionService +{ + /// + /// Generate AI suggestions for a batch of commits. + /// Returns detailed success/failure information for each commit. + /// + Task GenerateSuggestionsAsync( + IEnumerable analyses, + IProgress? progress = null, + CancellationToken ct = default); + + /// + /// Generate an AI suggestion for a single commit. + /// Returns detailed result including success/failure and error information. + /// + Task GenerateSuggestionAsync(CommitAnalysis analysis, CancellationToken ct = default); +} diff --git a/icon.png b/icon.png new file mode 100755 index 0000000..efdc7c3 Binary files /dev/null and b/icon.png differ