using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; namespace MarketAlly.AIPlugin.Refactoring.Plugins; /// /// GitHub repository cloning and management functionality /// public class GitHubCloneManager { private readonly int _timeoutMinutes = 30; private readonly long _maxRepoSizeBytes = 1024 * 1024 * 1024; // 1GB limit public async Task 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 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 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 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 CheckRepositoryAccessibilityAsync(string repositoryUrl) { try { var result = await ExecuteGitCommandAsync("git", $"ls-remote --heads \"{repositoryUrl}\"", timeoutMinutes: 1); return result.Success; } catch { return false; } } private async Task 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 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 { "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 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