Skip to content

Instantly share code, notes, and snippets.

@harsh183
Created September 24, 2025 19:15
Show Gist options
  • Select an option

  • Save harsh183/198c6b76e76bcc55ec8d5775c9a8beb9 to your computer and use it in GitHub Desktop.

Select an option

Save harsh183/198c6b76e76bcc55ec8d5775c9a8beb9 to your computer and use it in GitHub Desktop.
Stacked PR using jj version control (very vibe coded)
#!/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
@harsh183
Copy link
Author

Maxime had no idea why we require English πŸ˜…

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment