Created
September 24, 2025 19:15
-
-
Save harsh183/198c6b76e76bcc55ec8d5775c9a8beb9 to your computer and use it in GitHub Desktop.
Stacked PR using jj version control (very vibe coded)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| require 'English' | |
| require 'json' | |
| require 'tempfile' | |
| require 'fileutils' | |
| # StackedPR creates and manages stacked pull requests for Jujutsu commits | |
| class StackedPR | |
| def initialize | |
| @main_branch = main_branch | |
| @pr_template = read_pr_template | |
| end | |
| def run | |
| puts 'Creating stacked PRs...' | |
| puts "Main branch: #{@main_branch}" | |
| # Get all commits from main branch to current working copy parent | |
| commits = stack_commits | |
| if commits.empty? | |
| puts 'No commits to create PRs for' | |
| return | |
| end | |
| puts "Found #{commits.length} commits to process" | |
| last_branch = @main_branch | |
| pr_numbers = [] | |
| commits.each_with_index do |commit, index| | |
| puts "\n--- Processing commit #{index + 1}/#{commits.length} ---" | |
| puts "Change ID: #{commit[:change_id][0..11]}" | |
| puts "Description: #{commit[:description].split("\n").first}" | |
| # Create or get bookmark for this commit | |
| bookmark = ensure_bookmark(commit) | |
| # Push the bookmark | |
| unless push_bookmark(bookmark, commit[:change_id]) | |
| puts "Failed to push bookmark #{bookmark}" | |
| exit 1 | |
| end | |
| # Create PR if it doesn't exist | |
| pr_number = get_or_create_pr(bookmark, last_branch, commit[:description]) | |
| if pr_number.nil? | |
| puts "Failed to create PR for #{bookmark}" | |
| exit 1 | |
| end | |
| pr_numbers << pr_number | |
| last_branch = bookmark | |
| puts "β PR ##{pr_number} ready for #{bookmark}" | |
| end | |
| # Update all PRs with stack information | |
| update_pr_descriptions(pr_numbers, commits) | |
| puts "\nπ Created #{pr_numbers.length} stacked PRs:" | |
| pr_numbers.each { |pr| puts " - PR ##{pr}" } | |
| end | |
| private | |
| def main_branch | |
| # Read from .fj.toml if it exists, otherwise default to 'master' | |
| config_file = '.fj.toml' | |
| if File.exist?(config_file) | |
| content = File.read(config_file) | |
| return ::Regexp.last_match(1) if content =~ /mainBranch\s*=\s*"([^"]+)"/ | |
| end | |
| 'master' # Your preferred default | |
| end | |
| def read_pr_template | |
| template_path = '.github/pull_request_template.md' | |
| if File.exist?(template_path) | |
| File.read(template_path) | |
| else | |
| "### Problem\n\n### Solution\n\n### Risks\n" | |
| end | |
| end | |
| def stack_commits | |
| # Get commits from main branch to working copy parent (@-) | |
| # Use a unique separator that won't appear in descriptions | |
| separator = '|||COMMIT_SEP|||' | |
| cmd = ['jj', 'log', '--no-graph', '--reversed', '-r', "#{@main_branch}..@-", | |
| '-T', "change_id ++ \"#{separator}\" ++ description ++ \"#{separator}END\""] | |
| output = run_command_output(*cmd) | |
| return [] if output.nil? || output.strip.empty? | |
| # Split by the separator and parse each commit | |
| commits = [] | |
| output.strip.split("#{separator}END").each do |commit_block| | |
| next if commit_block.strip.empty? | |
| parts = commit_block.split(separator, 2) | |
| next if parts.length < 2 | |
| commits << { | |
| change_id: parts[0].strip, | |
| description: parts[1].strip | |
| } | |
| end | |
| commits | |
| end | |
| def ensure_bookmark(commit) | |
| # Check if commit already has a bookmark | |
| existing_bookmarks = get_bookmarks_for_commit(commit[:change_id]) | |
| unless existing_bookmarks.empty? | |
| # Use the first existing bookmark | |
| return existing_bookmarks.first | |
| end | |
| # Create a new bookmark | |
| bookmark_name = generate_bookmark_name(commit) | |
| cmd = ['jj', 'bookmark', 'create', bookmark_name, '-r', commit[:change_id]] | |
| if run_command(*cmd) | |
| puts "Created bookmark: #{bookmark_name}" | |
| bookmark_name | |
| else | |
| puts "Failed to create bookmark for #{commit[:change_id]}" | |
| exit 1 | |
| end | |
| end | |
| def get_bookmarks_for_commit(change_id) | |
| cmd = ['jj', 'bookmark', 'list', '-r', change_id] | |
| output = run_command_output(*cmd) | |
| return [] if output.nil? || output.strip.empty? | |
| # Parse bookmark output (format: "bookmark_name: change_id description") | |
| bookmarks = [] | |
| output.strip.split("\n").each do |line| | |
| bookmarks << ::Regexp.last_match(1).strip if line =~ /^([^:]+):/ | |
| end | |
| bookmarks | |
| end | |
| def generate_bookmark_name(commit) | |
| # Generate a bookmark name based on the first line of the commit description | |
| first_line = commit[:description].split("\n").first.to_s | |
| # Clean up the description to make a valid branch name | |
| name = first_line.downcase | |
| .gsub(/[^a-z0-9\s\-_]/, '') # Remove special chars | |
| .gsub(/\s+/, '-') # Replace spaces with hyphens | |
| .gsub(/-+/, '-') # Collapse multiple hyphens | |
| .gsub(/^-+|-+$/, '') # Remove leading/trailing hyphens | |
| # Limit length and add prefix | |
| name = name[0..30] if name.length > 30 | |
| "feature/#{name}-#{commit[:change_id][0..7]}" | |
| end | |
| def push_bookmark(_bookmark, change_id) | |
| cmd = ['jj', 'git', 'push', '-r', change_id, '--allow-new'] | |
| run_command(*cmd) | |
| end | |
| def get_or_create_pr(bookmark, base_branch, description) | |
| # Check if PR already exists | |
| existing_pr = get_existing_pr(bookmark) | |
| return existing_pr if existing_pr | |
| # Create new PR with template | |
| create_pr(bookmark, base_branch, description) | |
| end | |
| def get_existing_pr(bookmark) | |
| cmd = ['gh', 'pr', 'list', '--head', bookmark, '--json', 'number', '--jq', '.[0].number'] | |
| output = run_command_output(*cmd) | |
| return nil if output.nil? || output.strip.empty? || output.strip == 'null' | |
| output.strip.to_i | |
| end | |
| def create_pr(bookmark, base_branch, description) | |
| # Create a temporary file with the PR body | |
| pr_body = build_pr_body(description) | |
| Tempfile.create(['pr_body', '.md']) do |f| | |
| f.write(pr_body) | |
| f.flush | |
| # Extract title from first line of description | |
| title = description.split("\n").first.to_s.strip | |
| title = "Update from #{bookmark}" if title.empty? | |
| cmd = ['gh', 'pr', 'create', | |
| '--head', bookmark, | |
| '--base', base_branch, | |
| '--title', title, | |
| '--body-file', f.path, | |
| '--draft'] | |
| puts "Running: #{cmd.join(' ')}" | |
| output = run_command_output(*cmd) | |
| puts "GH Output: #{output.inspect}" | |
| if output && output =~ %r{/pull/(\d+)} | |
| ::Regexp.last_match(1).to_i | |
| else | |
| puts "Failed to create PR. Full output: #{output}" | |
| # Let's also try to run without redirect to see error | |
| system(*cmd) | |
| nil | |
| end | |
| end | |
| end | |
| def build_pr_body(description) | |
| lines = description.split("\n") | |
| body = lines[1..]&.join("\n")&.strip || '' | |
| if body.empty? | |
| # No body in commit, use template as-is for user to fill out | |
| @pr_template | |
| else | |
| # Has a body, use it as the full PR description | |
| description | |
| end | |
| end | |
| def update_pr_descriptions(pr_numbers, _commits) | |
| return if pr_numbers.length <= 1 | |
| puts "\nUpdating PR descriptions with stack information..." | |
| pr_numbers.each_with_index do |pr_number, index| | |
| # Get current PR body | |
| current_body = get_pr_body(pr_number) | |
| next if current_body.nil? | |
| # Add stack information | |
| stack_info = build_stack_info(pr_numbers, index) | |
| new_body = "#{current_body}\n\n#{stack_info}" | |
| # Update PR | |
| cmd = ['gh', 'pr', 'edit', pr_number.to_s, '--body', new_body] | |
| puts "β Updated PR ##{pr_number} with stack info" if run_command(*cmd) | |
| end | |
| end | |
| def get_pr_body(pr_number) | |
| cmd = ['gh', 'pr', 'view', pr_number.to_s, '--json', 'body', '--jq', '.body'] | |
| run_command_output(*cmd) | |
| end | |
| def build_stack_info(pr_numbers, current_index) | |
| info = "---\n**Stacked PRs:**\n" | |
| pr_numbers.each_with_index do |pr_num, index| | |
| info += if index == current_index | |
| "* **π ##{pr_num} (this PR)**\n" | |
| else | |
| "* ##{pr_num}\n" | |
| end | |
| end | |
| info | |
| end | |
| def run_command(*args) | |
| system(*args, out: File::NULL, err: File::NULL) | |
| end | |
| def run_command_output(*args) | |
| require 'shellwords' | |
| output = `#{args.shelljoin} 2>/dev/null` | |
| $CHILD_STATUS.success? ? output.strip : nil | |
| end | |
| end | |
| # Main execution | |
| if __FILE__ == $PROGRAM_NAME | |
| if ARGV.include?('--help') || ARGV.include?('-h') | |
| puts <<~HELP | |
| Stacked PR Creator for Jujutsu | |
| Usage: | |
| #{File.basename($PROGRAM_NAME)} Create stacked PRs for commits | |
| This script: | |
| - Finds all commits from your main branch to current working copy | |
| - Creates bookmarks for each commit if needed | |
| - Creates GitHub PRs using your PR template | |
| - Links PRs together in a stack | |
| Requirements: | |
| - jj (Jujutsu)#{' '} | |
| - gh (GitHub CLI) | |
| - .fj.toml config file (optional) | |
| HELP | |
| exit 0 | |
| end | |
| begin | |
| StackedPR.new.run | |
| rescue Interrupt | |
| puts "\n\nAborted by user" | |
| exit 1 | |
| rescue StandardError => e | |
| puts "Error: #{e.message}" | |
| exit 1 | |
| end | |
| end |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Maxime had no idea why we require English π