diff --git a/src/Ivy.Tendril.Test/Helpers/GitHelperTests.cs b/src/Ivy.Tendril.Test/Helpers/GitHelperTests.cs index 12c15788..8e090b7c 100644 --- a/src/Ivy.Tendril.Test/Helpers/GitHelperTests.cs +++ b/src/Ivy.Tendril.Test/Helpers/GitHelperTests.cs @@ -14,7 +14,33 @@ public GitHelperTests() public void Dispose() { if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); + { + try + { + DeleteDirectory(_tempDir); + } + catch { } + } + } + + private static void DeleteDirectory(string path) + { + foreach (var directory in Directory.GetDirectories(path)) + { + DeleteDirectory(directory); + } + + foreach (var file in Directory.GetFiles(path)) + { + var attr = File.GetAttributes(file); + if ((attr & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) + { + File.SetAttributes(file, attr & ~FileAttributes.ReadOnly); + } + File.Delete(file); + } + + Directory.Delete(path, false); } [Fact] @@ -82,4 +108,71 @@ public void ResolveRepoRootFromWorktree_RepoRootDoesNotExist_ReturnsNull() Assert.Null(result); } + + [Fact] + public async Task IsValidBranchAsync_InvalidArgs_ReturnsFalse() + { + Assert.False(await GitHelper.IsValidBranchAsync("", "main")); + Assert.False(await GitHelper.IsValidBranchAsync("path", "")); + } + + [Fact] + public async Task IsValidBranchAsync_NonExistentLocalPath_ReturnsFalse() + { + var nonExistent = Path.Combine(_tempDir, "nonexistent"); + Assert.False(await GitHelper.IsValidBranchAsync(nonExistent, "main")); + } + + [Fact] + public async Task IsValidBranchAsync_LocalRepo_ExistingAndNonExistingBranches_Works() + { + var repoPath = Path.Combine(_tempDir, "local-repo"); + Directory.CreateDirectory(repoPath); + InitGitRepo(repoPath, "dev-branch"); + + // Existing branch + Assert.True(await GitHelper.IsValidBranchAsync(repoPath, "dev-branch")); + + // Non-existing branch + Assert.False(await GitHelper.IsValidBranchAsync(repoPath, "main-nonexistent")); + } + + [Fact] + public async Task IsValidBranchAsync_RemoteUrl_ExistingAndNonExistingBranches_Works() + { + // Use a known public git repository + var remoteUrl = "https://github.com/git/git.git"; + + // If network is offline, ls-remote will fail and return false. + // We only verify assertions if we can successfully query the remote. + var isReachable = await GitHelper.IsValidBranchAsync(remoteUrl, "master"); + if (isReachable) + { + Assert.True(isReachable); + Assert.False(await GitHelper.IsValidBranchAsync(remoteUrl, "nonexistent-branch-12345")); + } + } + + private static void InitGitRepo(string path, string branchName) + { + using var pInit = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("git", "init") { WorkingDirectory = path, CreateNoWindow = true }); + pInit?.WaitForExit(); + + using var pEmail = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("git", "config user.email \"test@example.com\"") { WorkingDirectory = path, CreateNoWindow = true }); + pEmail?.WaitForExit(); + + using var pName = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("git", "config user.name \"Test User\"") { WorkingDirectory = path, CreateNoWindow = true }); + pName?.WaitForExit(); + + File.WriteAllText(Path.Combine(path, "test.txt"), "hello"); + + using var pAdd = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("git", "add test.txt") { WorkingDirectory = path, CreateNoWindow = true }); + pAdd?.WaitForExit(); + + using var pCommit = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("git", "commit -m \"initial\"") { WorkingDirectory = path, CreateNoWindow = true }); + pCommit?.WaitForExit(); + + using var pCheckout = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("git", $"checkout -b {branchName}") { WorkingDirectory = path, CreateNoWindow = true }); + pCheckout?.WaitForExit(); + } } diff --git a/src/Ivy.Tendril/Apps/Setup/Dialogs/EditProjectDialog.cs b/src/Ivy.Tendril/Apps/Setup/Dialogs/EditProjectDialog.cs index f9de5a56..4ac48cd4 100644 --- a/src/Ivy.Tendril/Apps/Setup/Dialogs/EditProjectDialog.cs +++ b/src/Ivy.Tendril/Apps/Setup/Dialogs/EditProjectDialog.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Ivy.Core.Hooks; using Ivy.Tendril.Apps.Onboarding; using Ivy.Tendril.Apps.Onboarding.Models; @@ -173,9 +174,25 @@ public class EditProjectDialog( new Button("Cancel").Outline().OnClick(() => _editIndex.Set(-1)), new Button(isNew ? "Add" : "Save").Primary() .Disabled(hasInvalidRepos) - .OnClick(() => + .OnClick(async () => { if (string.IsNullOrWhiteSpace(editName.Value)) return; + + // Perform base branch validation + foreach (var repo in editRepos.Value) + { + if (!string.IsNullOrWhiteSpace(repo.BaseBranch)) + { + var isValid = await GitHelper.IsValidBranchAsync(repo.Path, repo.BaseBranch, _config.TendrilHome); + if (!isValid) + { + var repoName = RepoPathValidator.ExtractRepoName(repo.Path) ?? repo.Path; + _client.Toast($"Branch '{repo.BaseBranch}' does not exist in repository '{repoName}'", "Error"); + return; + } + } + } + var project = isNew ? new ProjectConfig() : _projects[_editIndex.Value!.Value]; var oldName = project.Name; var oldColor = project.Color; diff --git a/src/Ivy.Tendril/Commands/ProjectCommand.cs b/src/Ivy.Tendril/Commands/ProjectCommand.cs index 85a17646..d9dee6fc 100644 --- a/src/Ivy.Tendril/Commands/ProjectCommand.cs +++ b/src/Ivy.Tendril/Commands/ProjectCommand.cs @@ -468,6 +468,16 @@ protected override int Execute(CommandContext context, ProjectAddRepoSettings se return 1; } + if (!string.IsNullOrWhiteSpace(settings.BaseBranch)) + { + var isValid = Ivy.Tendril.Helpers.GitHelper.IsValidBranchAsync(settings.RepoPath, settings.BaseBranch, config.TendrilHome).GetAwaiter().GetResult(); + if (!isValid) + { + _logger.LogError("Branch '{Branch}' does not exist in repository: {Path}", settings.BaseBranch, settings.RepoPath); + return 1; + } + } + project.Repos.Add(new RepoRef { Path = settings.RepoPath, diff --git a/src/Ivy.Tendril/Helpers/GitHelper.cs b/src/Ivy.Tendril/Helpers/GitHelper.cs index 81064a96..c3b206b7 100644 --- a/src/Ivy.Tendril/Helpers/GitHelper.cs +++ b/src/Ivy.Tendril/Helpers/GitHelper.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; +using System.IO; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace Ivy.Tendril.Helpers; @@ -17,4 +20,152 @@ public static class GitHelper var repoRoot = Path.GetDirectoryName(repoGitDir); return repoRoot != null && Directory.Exists(repoRoot) ? repoRoot : null; } + + public static async Task IsValidBranchAsync(string repoPath, string branchName, string? tendrilHome = null) + { + if (string.IsNullOrWhiteSpace(repoPath) || string.IsNullOrWhiteSpace(branchName)) + return false; + + var expandedPath = VariableExpansion.ExpandVariables(repoPath, tendrilHome); + var kind = RepoPathValidator.Classify(expandedPath); + if (kind == RepoPathKind.Invalid) + return false; + + if (kind == RepoPathKind.LocalPath) + { + if (!Directory.Exists(expandedPath)) + return false; + + return await Task.Run(() => + { + // Try local branch first: refs/heads/ + if (RunGitShowRef(expandedPath, $"refs/heads/{branchName}")) + return true; + + // Try remote branch: refs/remotes/origin/ + if (RunGitShowRef(expandedPath, $"refs/remotes/origin/{branchName}")) + return true; + + // Check other remotes + try + { + var psi = new ProcessStartInfo("git", "show-ref") + { + WorkingDirectory = expandedPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + var outTask = process.StandardOutput.ReadToEndAsync(); + var errTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(5000); + + var output = outTask.GetAwaiter().GetResult(); + _ = errTask.GetAwaiter().GetResult(); + + if (process.ExitCode == 0) + { + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var parts = line.Split(' ', 2); + if (parts.Length == 2) + { + var refName = parts[1].Trim(); + if (refName.Equals($"refs/heads/{branchName}", StringComparison.OrdinalIgnoreCase) || + (refName.StartsWith("refs/remotes/", StringComparison.OrdinalIgnoreCase) && refName.EndsWith($"/{branchName}", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + } + } + } + catch + { + // Ignore process execution errors + } + + return false; + }); + } + else // Remote URL: HttpUrl or SshUrl + { + return await Task.Run(() => + { + try + { + var psi = new ProcessStartInfo("git", $"ls-remote --heads \"{expandedPath}\" \"{branchName}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + var outTask = process.StandardOutput.ReadToEndAsync(); + var errTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(10000); + + var output = outTask.GetAwaiter().GetResult(); + _ = errTask.GetAwaiter().GetResult(); + + if (process.ExitCode == 0) + { + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.Contains($"refs/heads/{branchName}")) + return true; + } + } + } + catch + { + // Ignore process execution errors + } + + return false; + }); + } + } + + private static bool RunGitShowRef(string repoPath, string refName) + { + try + { + var psi = new ProcessStartInfo("git", $"show-ref --verify \"{refName}\"") + { + WorkingDirectory = repoPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + var outTask = process.StandardOutput.ReadToEndAsync(); + var errTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(5000); + + _ = outTask.GetAwaiter().GetResult(); + _ = errTask.GetAwaiter().GetResult(); + + return process.ExitCode == 0; + } + catch + { + return false; + } + } }