MarketAlly.AIPlugin.Extensions/MarketAlly.AIPlugin.Refacto.../GitHubCloneManager.cs

483 lines
16 KiB
C#
Executable File

using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace MarketAlly.AIPlugin.Refactoring.Plugins;
/// <summary>
/// GitHub repository cloning and management functionality
/// </summary>
public class GitHubCloneManager
{
private readonly int _timeoutMinutes = 30;
private readonly long _maxRepoSizeBytes = 1024 * 1024 * 1024; // 1GB limit
public async Task<GitRepositoryValidation> ValidateRepositoryAsync(string repositoryUrl)
{
var validation = new GitRepositoryValidation
{
RepositoryUrl = repositoryUrl
};
try
{
// Basic URL validation
if (!Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri))
{
validation.Error = "Invalid URL format";
return validation;
}
// Check if it's a supported Git host
var supportedHosts = new[] { "github.com", "gitlab.com", "bitbucket.org" };
if (!supportedHosts.Contains(uri.Host.ToLowerInvariant()))
{
validation.Error = $"Unsupported repository host: {uri.Host}";
return validation;
}
validation.RepositoryHost = uri.Host.ToLowerInvariant();
// Parse repository info from URL
var pathParts = uri.AbsolutePath.Trim('/').Split('/');
if (pathParts.Length >= 2)
{
validation.Owner = pathParts[0];
validation.Repository = pathParts[1].Replace(".git", "");
}
// Check repository accessibility using git ls-remote
var isAccessible = await CheckRepositoryAccessibilityAsync(repositoryUrl);
validation.IsAccessible = isAccessible;
validation.IsValid = isAccessible;
if (isAccessible)
{
validation.DefaultBranch = await DetectDefaultBranchAsync(repositoryUrl);
validation.IsPublic = await CheckIsPublicRepositoryAsync(repositoryUrl);
}
else
{
validation.Error = "Repository is not accessible or does not exist";
}
return validation;
}
catch (Exception ex)
{
validation.Error = $"Validation failed: {ex.Message}";
return validation;
}
}
public async Task<GitCloneResult> CloneRepositoryAsync(GitCloneOptions options)
{
var result = new GitCloneResult
{
RepositoryUrl = options.RepositoryUrl,
TargetPath = options.TargetPath,
Branch = options.Branch,
ClonedAt = DateTime.UtcNow
};
try
{
// Prepare target directory
await PrepareTargetDirectoryAsync(options.TargetPath, options.OverwriteExisting);
// Build git clone command
var arguments = BuildCloneArguments(options);
// Execute git clone
var cloneSuccess = await ExecuteGitCommandAsync("git", arguments, timeoutMinutes: _timeoutMinutes);
if (!cloneSuccess.Success)
{
result.Error = $"Git clone failed: {cloneSuccess.Error}";
return result;
}
// Get repository metadata
var gitManager = new SimpleGitManager(options.TargetPath);
if (gitManager.IsGitRepository)
{
var status = await gitManager.GetRepositoryStatus();
result.CommitHash = status.LatestCommitSha;
result.CommitMessage = status.LatestCommitMessage;
result.CommitAuthor = status.LatestCommitAuthor;
}
// Get directory size and file count
var (size, fileCount) = await GetDirectoryInfoAsync(options.TargetPath);
result.SizeBytes = size;
result.FileCount = fileCount;
// Check size limits
if (result.SizeBytes > _maxRepoSizeBytes)
{
result.Warning = $"Repository size ({result.SizeBytes / 1024 / 1024} MB) exceeds recommended limit";
}
result.Success = true;
return result;
}
catch (Exception ex)
{
result.Error = $"Clone operation failed: {ex.Message}";
// Cleanup on failure
try
{
if (Directory.Exists(options.TargetPath))
{
Directory.Delete(options.TargetPath, recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
return result;
}
}
public async Task<GitUpdateResult> UpdateRepositoryAsync(string repositoryPath, bool forceUpdate = false)
{
var result = new GitUpdateResult
{
RepositoryPath = repositoryPath,
UpdatedAt = DateTime.UtcNow
};
try
{
var gitManager = new SimpleGitManager(repositoryPath);
if (!gitManager.IsGitRepository)
{
result.Error = "Not a valid Git repository";
return result;
}
// Get current commit before update
var statusBefore = await gitManager.GetRepositoryStatus();
result.PreviousCommitHash = statusBefore.LatestCommitSha;
// Check for uncommitted changes
if (!statusBefore.IsClean && !forceUpdate)
{
result.Error = "Repository has uncommitted changes. Use force_update=true to override.";
return result;
}
// If force update and dirty, stash changes
if (!statusBefore.IsClean && forceUpdate)
{
var stashResult = await ExecuteGitCommandAsync("git", "stash push -m \"Auto-stash before update\"",
workingDirectory: repositoryPath);
result.StashedChanges = stashResult.Success;
}
// Execute git pull
var pullResult = await ExecuteGitCommandAsync("git", "pull origin",
workingDirectory: repositoryPath, timeoutMinutes: 10);
if (!pullResult.Success)
{
result.Error = $"Git pull failed: {pullResult.Error}";
return result;
}
// Get commit after update
var statusAfter = await gitManager.GetRepositoryStatus();
result.NewCommitHash = statusAfter.LatestCommitSha;
result.HasChanges = result.PreviousCommitHash != result.NewCommitHash;
if (result.HasChanges)
{
result.ChangedFiles = await GetChangedFilesCountAsync(repositoryPath,
result.PreviousCommitHash, result.NewCommitHash);
}
result.Success = true;
return result;
}
catch (Exception ex)
{
result.Error = $"Update failed: {ex.Message}";
return result;
}
}
public async Task<bool> CheckForRemoteUpdatesAsync(string repositoryPath)
{
try
{
// Fetch remote info without merging
var fetchResult = await ExecuteGitCommandAsync("git", "fetch --dry-run",
workingDirectory: repositoryPath, timeoutMinutes: 2);
// If fetch output is not empty, there are remote updates
return !string.IsNullOrWhiteSpace(fetchResult.Output);
}
catch
{
return false;
}
}
#region Private Helper Methods
private async Task<bool> CheckRepositoryAccessibilityAsync(string repositoryUrl)
{
try
{
var result = await ExecuteGitCommandAsync("git", $"ls-remote --heads \"{repositoryUrl}\"",
timeoutMinutes: 1);
return result.Success;
}
catch
{
return false;
}
}
private async Task<string?> DetectDefaultBranchAsync(string repositoryUrl)
{
try
{
var result = await ExecuteGitCommandAsync("git", $"ls-remote --symref \"{repositoryUrl}\" HEAD",
timeoutMinutes: 1);
if (result.Success)
{
var match = Regex.Match(result.Output, @"ref: refs/heads/(.+)\s+HEAD");
if (match.Success)
{
return match.Groups[1].Value.Trim();
}
}
return null;
}
catch
{
return null;
}
}
private async Task<bool> CheckIsPublicRepositoryAsync(string repositoryUrl)
{
// For now, assume accessible repositories are public
// Could be enhanced with API calls to check repository visibility
return true;
}
private async Task PrepareTargetDirectoryAsync(string targetPath, bool overwriteExisting)
{
// Normalize path for cross-platform compatibility
var normalizedPath = targetPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
if (Directory.Exists(normalizedPath))
{
if (!overwriteExisting)
{
throw new InvalidOperationException($"Target directory already exists: {normalizedPath}");
}
Directory.Delete(normalizedPath, recursive: true);
}
Directory.CreateDirectory(normalizedPath);
}
private string BuildCloneArguments(GitCloneOptions options)
{
var args = new List<string> { "clone" };
if (options.ShallowClone)
{
args.Add("--depth 1");
}
if (!string.IsNullOrEmpty(options.Branch))
{
args.Add($"--branch {options.Branch}");
args.Add("--single-branch");
}
// Build repository URL with access token for private repos
var repositoryUrl = options.RepositoryUrl;
if (!string.IsNullOrEmpty(options.AccessToken) && repositoryUrl.Contains("github.com"))
{
// For GitHub, inject token into the URL: https://token@github.com/owner/repo.git
repositoryUrl = repositoryUrl.Replace("https://github.com", $"https://{options.AccessToken}@github.com");
}
else if (!string.IsNullOrEmpty(options.AccessToken) && repositoryUrl.Contains("gitlab.com"))
{
// For GitLab, use oauth2 token format: https://oauth2:token@gitlab.com/owner/repo.git
repositoryUrl = repositoryUrl.Replace("https://gitlab.com", $"https://oauth2:{options.AccessToken}@gitlab.com");
}
// Normalize target path for git command (git always expects forward slashes)
var normalizedTargetPath = options.TargetPath.Replace('\\', '/');
args.Add($"\"{repositoryUrl}\"");
args.Add($"\"{normalizedTargetPath}\"");
return string.Join(" ", args);
}
private async Task<(bool Success, string Output, string Error)> ExecuteGitCommandAsync(
string command, string arguments, string? workingDirectory = null, int timeoutMinutes = 5)
{
try
{
var processInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(processInfo);
if (process == null)
{
return (false, "", "Failed to start process");
}
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(timeoutMinutes));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
process.Kill();
return (false, "", $"Command timed out after {timeoutMinutes} minutes");
}
var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
var success = process.ExitCode == 0;
return (success, output.Trim(), error.Trim());
}
catch (Exception ex)
{
return (false, "", $"Command execution failed: {ex.Message}");
}
}
private async Task<(long Size, int FileCount)> GetDirectoryInfoAsync(string directoryPath)
{
try
{
var files = Directory.GetFiles(directoryPath, "*", SearchOption.AllDirectories);
var totalSize = files.Sum(file => new FileInfo(file).Length);
return (totalSize, files.Length);
}
catch
{
return (0, 0);
}
}
private async Task<int> GetChangedFilesCountAsync(string repositoryPath, string fromCommit, string toCommit)
{
try
{
var result = await ExecuteGitCommandAsync("git", $"diff --name-only {fromCommit} {toCommit}",
workingDirectory: repositoryPath);
if (result.Success)
{
return result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length;
}
return 0;
}
catch
{
return 0;
}
}
#endregion
}
#region Data Models
public class GitCloneOptions
{
public string RepositoryUrl { get; set; } = string.Empty;
public string TargetPath { get; set; } = string.Empty;
public string Branch { get; set; } = "main";
public bool ShallowClone { get; set; } = true;
public bool OverwriteExisting { get; set; } = false;
public string? AccessToken { get; set; } // GitHub/GitLab access token for private repos
}
public class GitRepositoryValidation
{
public bool IsValid { get; set; }
public bool IsAccessible { get; set; }
public string RepositoryUrl { get; set; } = string.Empty;
public string? RepositoryHost { get; set; }
public string? Owner { get; set; }
public string? Repository { get; set; }
public string? DefaultBranch { get; set; }
public bool IsPublic { get; set; }
public string? Error { get; set; }
}
public class GitCloneResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? Warning { get; set; }
public string RepositoryUrl { get; set; } = string.Empty;
public string TargetPath { get; set; } = string.Empty;
public string Branch { get; set; } = string.Empty;
public string CommitHash { get; set; } = string.Empty;
public string CommitMessage { get; set; } = string.Empty;
public string CommitAuthor { get; set; } = string.Empty;
public DateTime ClonedAt { get; set; }
public long SizeBytes { get; set; }
public int FileCount { get; set; }
}
public class GitUpdateResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string RepositoryPath { get; set; } = string.Empty;
public string PreviousCommitHash { get; set; } = string.Empty;
public string NewCommitHash { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; }
public bool HasChanges { get; set; }
public int ChangedFiles { get; set; }
public bool StashedChanges { get; set; }
}
public class GitRepositoryStatus
{
public bool IsValid { get; set; }
public string RepositoryPath { get; set; } = string.Empty;
public string CurrentBranch { get; set; } = string.Empty;
public string LatestCommitSha { get; set; } = string.Empty;
public string LatestCommitMessage { get; set; } = string.Empty;
public string LatestCommitAuthor { get; set; } = string.Empty;
public string LatestCommitDate { get; set; } = string.Empty;
public bool IsClean { get; set; }
public string StatusOutput { get; set; } = string.Empty;
public bool HasRemoteUpdates { get; set; }
public string? Error { get; set; }
}
#endregion