483 lines
16 KiB
C#
Executable File
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 |