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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ Create a new worktree for a feature branch and open it instantly in a new tab:
hypr my-feature
```

### Review a pull request

Open a GitHub PR in a dedicated worktree, including PRs from forks:

```bash
hypr pr 123
hypr pr https://github.com/owner/repository/pull/123
```

Requires an authenticated GitHub CLI (`gh`). Reopening a PR preserves your local work.

### Cleanup
Clean up old worktrees. `hypr` checks if branches are merged or if their PRs are closed.

Expand Down
27 changes: 27 additions & 0 deletions docs/clireference.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,33 @@ Branch names are sanitized for the filesystem - slashes become hyphens. For exam

You can customize this with the `directory_pattern` setting (see [Configuration](configuration.md)), which supports template variables like `{repo_name}`, `{branch}`, `{repo_parent_dir}`, and environment variables. For example, to organize all worktrees in a central location: `~/.worktrees/{repo_name}/{branch}`.

## `hypr pr <number-or-url>`

Open a GitHub pull request in its own worktree. Requires the GitHub CLI (`gh`),
authenticated with access to the repository, and Git access to the configured remote.

```bash
hypr pr 123
hypr pr https://github.com/owner/repository/pull/123
hypr pr 123 --terminal vscode --after-init "npm test" -y
```

The PR's base repository must match a configured remote. Forked PRs are fetched
through the base repository's PR ref, so no fork remote is needed. A dedicated
local branch named `pr-123` keeps review work separate from the contributor's branch.
An unrelated existing branch with that name is rejected.

Running the command again opens the existing worktree and preserves local commits
and uncommitted changes. It does not update the branch to the latest PR revision.
The existing create hooks and session initialization settings apply.

| Option | Description |
| --- | --- |
| `--dir <path>` | Override the directory pattern when creating a worktree. |
| `--terminal <mode>` | Override the terminal mode. |
| `--after-init <script>` | Run a command after session initialization. |
| `-y`, `--yes` | Skip the worktree creation confirmation. |

## `hypr ls`

_(Aliases: `list`, `ll`)_
Expand Down
47 changes: 47 additions & 0 deletions src/Commands/PrCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.CommandLine;
using Hypr.Configuration;
using Hypr.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Spectre.Console;

namespace Hypr.Commands;

public sealed class PrCommand : Command
{
private Argument<string> PullRequest { get; } = new("pull-request") { Description = "PR number or HTTPS URL" };
private Option<DirectoryInfo?> DirectoryOption { get; } = new("--dir") { Description = "Directory for a new worktree" };
private Option<TerminalMode?> TerminalOption { get; } = new("--terminal") { Description = "Terminal mode" };
private Option<string?> AfterInit { get; } = new("--after-init") { Description = "Command to run after session init" };
private AutoConfirmOption Yes { get; } = new();

public PrCommand(PullRequestWorktreeService service, IOptionsMonitor<TerminalConfig> terminal, ILogger<PrCommand> logger)
: base("pr", "Open a GitHub pull request in a worktree")
{
Add(PullRequest);
Add(DirectoryOption);
Add(TerminalOption);
Add(AfterInit);
Add(Yes);
SetAction(async context =>
{
try
{
var worktree = await service.ResolveAsync(context.GetRequiredValue(PullRequest), context.GetValue(DirectoryOption));
if (!service.Exists(worktree) && !context.GetValue(Yes)
&& !AnsiConsole.Confirm($"Create PR #{worktree.Number} worktree at {Markup.Escape(worktree.Path)}?"))
return 0;
await service.CreateAsync(worktree);
AnsiConsole.MarkupLine($"[green]Opening PR #{worktree.Number}:[/] {Markup.Escape(worktree.Path)}");
return service.Open(worktree, context.GetValue(TerminalOption) ?? terminal.CurrentValue.Mode,
context.GetValue(AfterInit)) ? 0 : 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to open pull request worktree");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
return 1;
}
});
}
}
24 changes: 1 addition & 23 deletions src/Commands/SwitchCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ private int Execute(ParseResult ctx)
AnsiConsole.MarkupLine($"[yellow]Creating new worktree for branch:[/] {branch}");

// Generate worktree path
var worktreePath = GenerateWorktreePath(repoPath, branch, dir, worktreeConfig);
var worktreePath = worktreeConfig.GetPath(repoPath, branch, dir);

// Run pre-create hook
if (!string.IsNullOrEmpty(scriptsConfig.PreCreate))
Expand Down Expand Up @@ -206,26 +206,4 @@ private int Execute(ParseResult ctx)
}
}

private static string GenerateWorktreePath(string repoPath, string branch, DirectoryInfo? customDir, WorktreeConfig config)
{
if (customDir is not null)
return customDir.FullName;

// Use directory pattern from config
var pattern = config.DirectoryPattern;
var repoName = Path.GetFileName(repoPath);

// Replace placeholders
var path = pattern
.Replace("{repo_name}", repoName)
.Replace("{branch}", branch);

// Make it absolute relative to repo
if (!Path.IsPathRooted(path))
{
path = Path.Combine(repoPath, path);
}

return Path.GetFullPath(path);
}
}
7 changes: 7 additions & 0 deletions src/Configuration/WorktreeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ namespace Hypr.Configuration;
/// </summary>
public class WorktreeConfig
{
public string GetPath(string repoPath, string branch, DirectoryInfo? directory = null)
{
if (directory != null) return directory.FullName;
var path = DirectoryPattern.Replace("{repo_name}", Path.GetFileName(repoPath)).Replace("{branch}", branch);
return Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(repoPath, path));
}

/// <summary>
/// Path pattern for worktrees.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions src/Module.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal static IServiceCollection AddServices(this IServiceCollection services,
.AddSingleton<StateService>()
.AddSingleton<GitService>()
.AddSingleton<GitHubService>()
.AddSingleton<PullRequestWorktreeService>()
.AddSingleton<TerminalService>()
.AddSingleton<VersionCheckService>()
.AddSingleton<HookRunner>()
Expand All @@ -33,6 +34,7 @@ internal static IServiceCollection AddServices(this IServiceCollection services,
internal static IServiceCollection AddCommands(this IServiceCollection services) => services
.AddSingleton<Command, ListCommand>()
.AddSingleton<Command, SwitchCommand>()
.AddSingleton<Command, PrCommand>()
.AddSingleton<Command, ConfigCommand>()
.AddSingleton<Command, CleanupCommand>();

Expand Down
6 changes: 3 additions & 3 deletions src/Services/GitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,11 @@ public async Task<PrStatus> GetPullRequestStatusForBranch(string repoPath, strin
/// <returns>A tuple containing exitCode, stdout and stderr.</returns>
public static async Task<(int exitCode, string stdout, string stderr)> RunProcessAsync(string command, string[] args, string repoRoot, TimeSpan? timeout = null)
{
var process = new Process
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = string.Join(" ", args.Select(arg => $"\"{arg}\"")),
WorkingDirectory = repoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
Expand All @@ -286,6 +285,7 @@ public async Task<PrStatus> GetPullRequestStatusForBranch(string repoPath, strin
}
};

foreach (var arg in args) process.StartInfo.ArgumentList.Add(arg);
process.Start();

var stdoutTask = process.StandardOutput.ReadToEndAsync();
Expand All @@ -312,4 +312,4 @@ public async Task<PrStatus> GetPullRequestStatusForBranch(string repoPath, strin

return (process.ExitCode, stdout, stderr);
}
}
}
112 changes: 112 additions & 0 deletions src/Services/PullRequestWorktreeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Globalization;
using System.Text.Json;
using Hypr.Configuration;
using Hypr.Hooks;
using LibGit2Sharp;
using Microsoft.Extensions.Options;

namespace Hypr.Services;

public record PullRequestWorktree(string RepositoryPath, string Remote, int Number, string Url, string Branch, string Path);

public class PullRequestWorktreeService(
GitService git, HookRunner hooks, TerminalService terminals,
IOptionsMonitor<WorktreeConfig> worktreeConfig, IOptionsMonitor<ScriptsConfig> scripts)
{
public async Task<PullRequestWorktree> ResolveAsync(string input, DirectoryInfo? directory)
{
if (!(int.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out var number) && number > 0)
&& !TryParsePullRequestUrl(input, out _, out _))
throw new ArgumentException("Specify a positive PR number or an HTTPS pull request URL.");

var root = git.FindRepoRoot() ?? throw new InvalidOperationException("Not in a git repository.");
using var repository = new Repository(root);
root = repository.Info.WorkingDirectory ?? root;
var (exitCode, stdout, stderr) = await GitHubService.RunProcessAsync(
"gh", ["pr", "view", input, "--json", "url"], root, TimeSpan.FromSeconds(30));
if (exitCode != 0)
throw new InvalidOperationException($"Could not resolve PR. Check 'gh auth status'. {stderr.Trim()}");
using var document = JsonDocument.Parse(stdout);
var url = document.RootElement.GetProperty("url").GetString() ?? "";
if (!TryParsePullRequestUrl(url, out var identity, out number))
throw new InvalidOperationException("GitHub returned an invalid pull request URL.");
var remote = repository.Network.Remotes.FirstOrDefault(r => RemoteIdentity(r.Url) == identity);
if (remote == null)
throw new InvalidOperationException("The PR repository does not match any remote in this repository.");

var branch = $"pr-{number}";
var existing = git.ListWorktrees(root).FirstOrDefault(w => w.Branch == branch);
var path = existing?.Path ?? worktreeConfig.CurrentValue.GetPath(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), branch, directory);
return new(root, remote.Name, number, url, branch, path);
}

public bool Exists(PullRequestWorktree worktree) =>
git.ListWorktrees(worktree.RepositoryPath).Any(w => w.Branch == worktree.Branch);

public async Task CreateAsync(PullRequestWorktree worktree)
{
using var repository = new Repository(worktree.RepositoryPath);
var key = $"branch.{worktree.Branch}.hypr-pr-url";
if (repository.Branches[worktree.Branch] != null)
{
if (repository.Config.Get<string>(key)?.Value != worktree.Url)
throw new InvalidOperationException($"Branch '{worktree.Branch}' already exists and is not associated with this PR.");
if (Exists(worktree)) return;
}
else
{
var fetchRef = $"refs/hypr/pr-fetch/{Guid.NewGuid():N}";
try
{
var (exitCode, _, stderr) = await GitHubService.RunProcessAsync("git",
["fetch", "--no-tags", "--no-write-fetch-head", worktree.Remote, $"refs/pull/{worktree.Number}/head:{fetchRef}"],
worktree.RepositoryPath, TimeSpan.FromSeconds(60));
if (exitCode != 0) throw new InvalidOperationException($"Could not fetch PR: {stderr.Trim()}");
var commit = repository.Lookup<Commit>(fetchRef)
?? throw new InvalidOperationException("Fetched PR has no commit.");
repository.CreateBranch(worktree.Branch, commit);
repository.Config.Set(key, worktree.Url);
}
finally
{
if (repository.Refs[fetchRef] != null) repository.Refs.Remove(fetchRef);
}
}

if (!hooks.RunHook(scripts.CurrentValue.PreCreate, worktree.RepositoryPath))
throw new InvalidOperationException("Pre-create hook failed.");
if (!git.CreateWorktree(worktree.RepositoryPath, worktree.Path, worktree.Branch))
throw new InvalidOperationException("Could not create PR worktree.");
if (!hooks.RunHook(scripts.CurrentValue.PostCreate, worktree.Path))
throw new InvalidOperationException($"Post-create hook failed. Worktree is available at {worktree.Path}.");
hooks.RunHook(scripts.CurrentValue.PostCreateAsync, worktree.Path, async: true);
}

public bool Open(PullRequestWorktree worktree, TerminalMode mode, string? afterInit) =>
terminals.OpenWorktree(worktree.Path, mode, scripts.CurrentValue.SessionInit, afterInit);

private static bool TryParsePullRequestUrl(string input, out string identity, out int number)
{
identity = "";
number = 0;
if (!Uri.TryCreate(input, UriKind.Absolute, out var uri) || uri.Scheme != "https") return false;
var parts = uri.AbsolutePath.Trim('/').Split('/');
if (parts.Length != 4 || parts[2] != "pull" ||
!int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out number) || number <= 0) return false;
identity = $"{uri.Host}/{parts[0]}/{parts[1]}".ToLowerInvariant();
return true;
}

private static string RemoteIdentity(string remote)
{
string identity;
if (Uri.TryCreate(remote, UriKind.Absolute, out var uri) && !uri.IsFile)
identity = uri.Host + "/" + uri.AbsolutePath.Trim('/');
else
{
var at = remote.IndexOf('@');
identity = remote[(at + 1)..].Replace(':', '/').TrimEnd('/');
}
return (identity.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? identity[..^4] : identity).ToLowerInvariant();
}
}
Loading
Loading