Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion src/Ivy.Tendril.Test/Helpers/GitHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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();
}
}
19 changes: 18 additions & 1 deletion src/Ivy.Tendril/Apps/Setup/Dialogs/EditProjectDialog.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Threading.Tasks;
using Ivy.Core.Hooks;
using Ivy.Tendril.Apps.Onboarding;
using Ivy.Tendril.Apps.Onboarding.Models;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/Ivy.Tendril/Commands/ProjectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
151 changes: 151 additions & 0 deletions src/Ivy.Tendril/Helpers/GitHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace Ivy.Tendril.Helpers;

Expand All @@ -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<bool> 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/<branchName>
if (RunGitShowRef(expandedPath, $"refs/heads/{branchName}"))
return true;

// Try remote branch: refs/remotes/origin/<branchName>
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;
}
}
}