diff --git a/spec/command_line.md b/spec/command_line.md index 52066c2..695f2fe 100644 --- a/spec/command_line.md +++ b/spec/command_line.md @@ -98,6 +98,38 @@ try . # Shorthand (requires name) - Returns shell script to cd into worktree - `try .` without a name is NOT supported (too easy to invoke accidentally) +### pr + +Checkout a pull request into a dated directory. + +``` +try pr [name] +try exec pr [name] +try [name] # URL shorthand +``` + +**Arguments:** +- `arg` (required): PR ID (integer), `user/repo#pr-number`, or a GitHub PR URL +- `name` (optional): Custom name suffix + +**Behavior:** +- If `arg` is a GitHub PR URL or `user/repo#pr-number`: + - Clones the repository into `YYYY-MM-DD---pr-` (or custom name) + - Fetches the PR ref (`pull//head`) + - Checks out `FETCH_HEAD` (detached HEAD) +- If `arg` is a numeric ID and run inside a git repository: + - Creates a worktree in `YYYY-MM-DD--pr-` (or custom name) + - Fetches the PR ref (`pull//head`) in the main repository + - Checks out `FETCH_HEAD` in the worktree +- Returns shell script to cd into the directory + +**Examples:** +``` +try pr 123 # From current repo via worktree +try pr user/repo#456 # From specific repo via clone +try pr https://github.com/user/repo/pull/789 +``` + ### init Output shell function definition for shell integration. diff --git a/spec/tests/test_38_pr.sh b/spec/tests/test_38_pr.sh new file mode 100644 index 0000000..5387650 --- /dev/null +++ b/spec/tests/test_38_pr.sh @@ -0,0 +1,58 @@ +# PR command tests +# Spec: command_line.md (pr command) + +section "pr" + +# Test 1: PR with URL (default naming) +output=$(try_run --path="$TEST_TRIES" exec pr https://github.com/user/repo/pull/123 2>&1) +if echo "$output" | grep -q "git clone" && echo "$output" | grep -q "pull/123/head" && echo "$output" | grep -q "user-repo-pr-123"; then + pass +else + fail "pr with URL should clone and fetch pr" "contains git clone and pull/123/head" "$output" "command_line.md#pr" +fi + +# Test 2: PR with URL and custom name +output=$(try_run --path="$TEST_TRIES" exec pr https://github.com/user/repo/pull/123 my-custom-pr 2>&1) +if echo "$output" | grep -q "git clone" && echo "$output" | grep -q "pull/123/head" && echo "$output" | grep -q "my-custom-pr"; then + pass +else + fail "pr with URL and custom name should clone to custom name" "contains my-custom-pr" "$output" "command_line.md#pr" +fi + +# Test 3: PR shorthand user/repo#id +output=$(try_run --path="$TEST_TRIES" exec pr user/repo#456 2>&1) +if echo "$output" | grep -q "git clone" && echo "$output" | grep -q "pull/456/head" && echo "$output" | grep -q "user-repo-pr-456"; then + pass +else + fail "pr with user/repo#id shorthand should clone and fetch pr" "contains git clone and pull/456/head" "$output" "command_line.md#pr" +fi + +# Test 4: Numeric ID outside git repository should error +# Start in the $TEST_TRIES which is not a git repo +output=$(cd "$TEST_TRIES" && try_run --path="$TEST_TRIES" exec pr 789 2>&1) +exit_code=$? +if [ $exit_code -ne 0 ] && echo "$output" | grep -qi "error" && echo "$output" | grep -qi "git repository"; then + pass +else + fail "numeric ID outside git repository should fail with error" "exit code non-zero, error message" "exit=$exit_code output=$output" "command_line.md#pr" +fi + +# Test 5: Numeric ID inside git repository should fetch and create worktree +FAKE_REPO=$(mktemp -d) +(cd "$FAKE_REPO" && git init -q) +# Ensure we run from inside the fake git repository +output=$(cd "$FAKE_REPO" && try_run --path="$TEST_TRIES" exec pr 789 2>&1) +if echo "$output" | grep -q "worktree add" && echo "$output" | grep -q "pull/789/head"; then + pass +else + fail "numeric ID inside git repository should fetch and add worktree" "contains worktree add and pull/789/head" "$output" "command_line.md#pr" +fi +rm -rf "$FAKE_REPO" + +# Test 6: Shorthand PR URL direct execution (no pr command keyword) +output=$(try_run --path="$TEST_TRIES" exec https://github.com/user/repo/pull/123 2>&1) +if echo "$output" | grep -q "git clone" && echo "$output" | grep -q "pull/123/head" && echo "$output" | grep -q "user-repo-pr-123"; then + pass +else + fail "direct PR URL shorthand should clone and fetch pr" "contains git clone and pull/123/head" "$output" "command_line.md#pr" +fi diff --git a/try.rb b/try.rb index d966f80..86a1aed 100755 --- a/try.rb +++ b/try.rb @@ -972,18 +972,23 @@ def print_global_help Usage: try [query] Interactive directory selector try clone Clone repo into dated directory + try pr Checkout PR into dated directory try worktree Create worktree from current git repo try --help Show this help Commands: init [path] Output shell function definition clone [name] Clone git repo into date-prefixed directory + pr [name] Checkout a PR into a dated directory worktree Create worktree in dated directory Examples: try Open interactive selector try project Selector with initial filter try clone https://github.com/user/repo + try pr 123 + try pr user/repo#456 + try pr https://github.com/user/repo/pull/789 try worktree feature-branch Manual mode (without alias): @@ -1077,6 +1082,15 @@ def is_git_uri?(arg) arg.match?(%r{^(https?://|git@)}) || arg.include?('github.com') || arg.include?('gitlab.com') || arg.end_with?('.git') end + def is_github_pr_uri?(arg) + return false unless arg + arg.match?(%r{^https?://github\.com/[^/]+/[^/]+/pull/\d+}) + end + + def is_inside_git_repo? + system("git rev-parse --is-inside-work-tree >/dev/null 2>&1") + end + # Extract all options BEFORE getting command (they can appear anywhere) tries_path = extract_option_with_value!(ARGV, '--path') || TrySelector::TRY_PATH tries_path = File.expand_path(tries_path) @@ -1169,6 +1183,78 @@ def cmd_clone!(args, tries_path) script_clone(File.join(tries_path, dir_name), git_uri) end + def cmd_pr!(args, tries_path) + pr_arg = args.shift + custom_name = args.shift + + unless pr_arg + warn "Error: PR argument required" + warn "Usage: try pr [name]" + exit 1 + end + + # Handle different PR argument formats + if pr_arg =~ %r{^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)} + user, repo, pr_id = $1, $2, $3 + git_uri = "https://github.com/#{user}/#{repo}.git" + dir_name = if custom_name && !custom_name.empty? + custom_name + else + "#{user}-#{repo}-pr-#{pr_id}" + end + date_prefix = Time.now.strftime("%Y-%m-%d") + dir_name = resolve_unique_name_with_versioning(tries_path, date_prefix, dir_name) + full_path = File.join(tries_path, "#{date_prefix}-#{dir_name}") + + script_clone_pr(full_path, git_uri, pr_id) + + elsif pr_arg =~ %r{^([^/]+)/([^#]+)#(\d+)$} + user, repo, pr_id = $1, $2, $3 + git_uri = "https://github.com/#{user}/#{repo}.git" + dir_name = if custom_name && !custom_name.empty? + custom_name + else + "#{user}-#{repo}-pr-#{pr_id}" + end + date_prefix = Time.now.strftime("%Y-%m-%d") + dir_name = resolve_unique_name_with_versioning(tries_path, date_prefix, dir_name) + full_path = File.join(tries_path, "#{date_prefix}-#{dir_name}") + + script_clone_pr(full_path, git_uri, pr_id) + + elsif pr_arg =~ /^\d+$/ + pr_id = pr_arg + unless is_inside_git_repo? + warn "Error: Not inside a git repository. Cannot run 'try pr ' without repository context." + exit 1 + end + + repo_dir = Dir.pwd + repo_name = File.basename(repo_dir) + remote_url = `git config --get remote.origin.url 2>/dev/null`.strip rescue nil + if remote_url && !remote_url.empty? + parsed = parse_git_uri(remote_url) + repo_name = parsed[:repo] if parsed && parsed[:repo] + end + + dir_name = if custom_name && !custom_name.empty? + custom_name + else + "#{repo_name}-pr-#{pr_id}" + end + date_prefix = Time.now.strftime("%Y-%m-%d") + dir_name = resolve_unique_name_with_versioning(tries_path, date_prefix, dir_name) + full_path = File.join(tries_path, "#{date_prefix}-#{dir_name}") + + script_worktree_pr(full_path, repo_dir, pr_id) + + else + warn "Error: Invalid PR argument format: #{pr_arg}" + warn "Usage: try pr [name]" + exit 1 + end + end + def cmd_init!(args, tries_path) script_path = File.expand_path($0) @@ -1317,6 +1403,10 @@ def cmd_cd!(args, tries_path, and_type, and_exit, and_keys, and_confirm) return cmd_clone!(args[1..-1] || [], tries_path) end + if args.first == "pr" + return cmd_pr!(args[1..-1] || [], tries_path) + end + # Support: try . [name] and try ./path [name] if args.first && args.first.start_with?('.') path_arg = args.shift @@ -1346,6 +1436,24 @@ def cmd_cd!(args, tries_path, and_type, and_exit, and_keys, and_confirm) search_term = args.join(' ') + # GitHub PR URL shorthand → pr workflow + if is_github_pr_uri?(search_term.split.first) + pr_url, custom_name = search_term.split(/\s+/, 2) + if pr_url =~ %r{^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)} + user, repo, pr_id = $1, $2, $3 + git_uri = "https://github.com/#{user}/#{repo}.git" + dir_name = if custom_name && !custom_name.empty? + custom_name + else + "#{user}-#{repo}-pr-#{pr_id}" + end + date_prefix = Time.now.strftime("%Y-%m-%d") + dir_name = resolve_unique_name_with_versioning(tries_path, date_prefix, dir_name) + full_path = File.join(tries_path, "#{date_prefix}-#{dir_name}") + return script_clone_pr(full_path, git_uri, pr_id) + end + end + # Git URL shorthand → clone workflow if is_git_uri?(search_term.split.first) git_uri, custom_name = search_term.split(/\s+/, 2) @@ -1431,6 +1539,31 @@ def script_worktree(path, repo = nil) ["mkdir -p #{q(path)}", "echo #{q("Using git worktree to create this trial from #{src}.")}", worktree_cmd] + script_cd(path) end + def script_clone_pr(path, uri, pr_id) + branch_name = "pr-#{pr_id}" + [ + "mkdir -p #{q(path)}", + "echo #{q("Using git clone to create this trial from #{uri} PR ##{pr_id}.")}", + "git clone '#{uri}' #{q(path)}", + "cd #{q(path)}", + "git fetch origin pull/#{pr_id}/head", + "git checkout -q -B #{q(branch_name)} FETCH_HEAD", + "echo #{q(path)}" + ] + end + + def script_worktree_pr(path, repo_dir, pr_id) + r = repo_dir ? q(repo_dir) : nil + git_cmd = r ? "git -C #{r}" : "git" + branch_name = File.basename(path) + [ + "#{git_cmd} fetch origin pull/#{pr_id}/head", + "#{git_cmd} worktree add -b #{q(branch_name)} #{q(path)} FETCH_HEAD", + "echo #{q(path)}", + "cd #{q(path)}" + ] + end + def script_delete(paths, base_path) cmds = ["cd #{q(base_path)}"] paths.each { |item| cmds << "test -d #{q(item[:basename])} && rm -rf #{q(item[:basename])}" } @@ -1529,6 +1662,9 @@ def worktree_path(tries_path, repo_dir, custom_name) when 'clone' emit_script(cmd_clone!(ARGV, tries_path)) exit 0 + when 'pr' + emit_script(cmd_pr!(ARGV, tries_path)) + exit 0 when 'init' cmd_init!(ARGV, tries_path) exit 0 @@ -1541,6 +1677,10 @@ def worktree_path(tries_path, repo_dir, custom_name) when 'clone' ARGV.shift emit_script(cmd_clone!(ARGV, tries_path)) + when 'pr' + ARGV.shift + emit_script(cmd_pr!(ARGV, tries_path)) + exit 0 when 'worktree' ARGV.shift repo = ARGV.shift