#!/usr/bin/env ruby # frozen_string_literal: true require 'optparse' require 'net/http' require 'uri' require 'fileutils' require 'tmpdir' require 'rubygems/package' require 'zlib' require 'stringio' require 'shellwords' require 'json' class HackorumPatch VERSION = '1.2.1' def initialize(argv) @options = {} @topic_id = nil @branch_name = nil @temp_dir = nil @local_archive = nil @using_local_file = false @using_diff_mode = false @metadata = nil parse_options!(argv) if argv.empty? puts @option_parser exit 0 end first_arg = argv.shift # Auto-detect: numeric = topic_id, otherwise = local tar.gz file if first_arg =~ /^\d+$/ @topic_id = first_arg @branch_name = argv.shift || "review/t#{@topic_id}" @server_url = @options[:server] || 'https://hackorum.dev' else @local_archive = first_arg @using_local_file = true # Extract topic id from filename if present, otherwise use generic name if File.basename(@local_archive) =~ /topic-(\d+)-patchset/ @topic_id = $1 @branch_name = argv.shift || "review/t#{@topic_id}" else @branch_name = argv.shift || "review/patches" end end end def run print_banner check_version validate_arguments! validate_git_repository! worktree_info = detect_worktree_usage use_worktree = should_use_worktree?(worktree_info) validate_clean_working_directory! unless use_worktree check_worktree_directory!(worktree_info) if use_worktree check_branch_existence!(use_worktree) patch_files = if @using_local_file extract_local_patchset else download_and_extract_patchset end base_commit = detect_base_commit(File.dirname(patch_files.first)) if use_worktree worktree_path = determine_worktree_path(worktree_info) create_worktree(base_commit, worktree_path) Dir.chdir(worktree_path) else create_branch(base_commit) end copy_files_to_git_dir(patch_files) if @using_diff_mode apply_diffs(patch_files) else apply_patches(patch_files) end cleanup_temp_files print_success_message(use_worktree ? worktree_path : nil) rescue => e cleanup_temp_files puts "\nERROR: #{e.message}" exit 1 end private def verbose_log(msg) puts msg if @options[:verbose] end def sort_patch_files(files) files.sort_by do |f| basename = File.basename(f) # Match sequence number: first 4+ digit group followed by a hyphen # e.g., "0001-subject.patch" or "nocfbot.v2-0005-subject.patch" if basename =~ /(\d{4,})-.*\.(patch|diff)$/i [$1.to_i, basename] else # Fallback: sort alphabetically but after numbered patches [Float::INFINITY, basename] end end end def parse_options!(argv) @option_parser = OptionParser.new do |opts| opts.banner = "Usage: hackorum-patch [] [OPTIONS]" opts.separator "" opts.separator "Download and apply PostgreSQL patches from Hackorum" opts.separator "" opts.separator "Arguments:" opts.separator " topic_id Topic ID from Hackorum (numeric)" opts.separator " archive.tar.gz Local patchset archive file" opts.separator " branch_name Custom branch name (optional)" opts.separator "" opts.separator "Options:" opts.on("--force", "Overwrite existing branch") do @options[:force] = true end opts.on("--base-commit=COMMIT", "Specify base commit (default: auto-detect)") do |commit| @options[:base_commit] = commit end opts.on("--worktree-path=PATH", "Specify worktree location") do |path| @options[:worktree_path] = path end opts.on("--worktree=MODE", [:yes, :no, :auto], "Worktree mode: yes, no, auto (default: auto)") do |mode| @options[:worktree] = mode end opts.on("--server=URL", "Server URL (default: https://hackorum.dev)") do |url| @options[:server] = url end opts.on("--verbose", "Print detailed base commit detection info") do @options[:verbose] = true end opts.on("-h", "--help", "Show this help") do puts opts exit end opts.on("-v", "--version", "Show version") do puts "hackorum-patch v#{VERSION}" exit end end @option_parser.parse!(argv) end def print_banner puts "Hackorum Patch Downloader v#{VERSION}" puts "=" * 35 puts "" if @using_local_file puts "Archive: #{@local_archive}" puts "Branch: #{@branch_name}" else puts "Topic: #{@topic_id}" puts "Branch: #{@branch_name}" puts "Server: #{@server_url}" end puts "" end def check_version server_url = @server_url || @options[:server] || 'https://hackorum.dev' version_url = "#{server_url}/scripts/hackorum-patch/version" begin uri = URI(version_url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == 'https' http.open_timeout = 2 http.read_timeout = 2 request = Net::HTTP::Get.new(uri) response = http.request(request) return unless response.is_a?(Net::HTTPSuccess) data = JSON.parse(response.body) remote_version = data['current_version'] return unless remote_version local_ver = Gem::Version.new(VERSION) remote_ver = Gem::Version.new(remote_version) if remote_ver > local_ver puts "[UPDATE] A newer version (#{remote_version}) is available!" newer_versions = (data['versions'] || []).select do |v| Gem::Version.new(v['version']) > local_ver end newer_versions.each do |v| puts " Changes in #{v['version']}:" (v['changes'] || []).each do |change| puts " - #{change}" end end puts " Download: #{server_url}/help/hackorum-patch" puts "" end rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, JSON::ParserError, SocketError # Silently ignore version check failures - don't block the script end end def validate_arguments! if @using_local_file unless File.exist?(@local_archive) raise "Archive file not found: #{@local_archive}" end unless @local_archive =~ /\.tar\.gz$/i || @local_archive =~ /\.tgz$/i raise "Archive must be a .tar.gz or .tgz file" end else unless @topic_id && @topic_id =~ /^\d+$/ raise "Invalid topic ID. Must be a number." end end end def validate_git_repository! print "Checking git repository... " unless system("git rev-parse --git-dir > /dev/null 2>&1") puts "[FAIL]" raise "Not a git repository" end puts "[OK]" end def validate_clean_working_directory! print "Checking working directory... " status = `git status --porcelain`.strip unless status.empty? puts "[FAIL]" raise "Working directory has uncommitted changes. Commit or stash first." end puts "[OK]" end def check_worktree_directory!(worktree_info) worktree_path = determine_worktree_path(worktree_info) print "Checking worktree directory... " if File.exist?(worktree_path) if @options[:force] puts "[OK] (will overwrite)" else puts "[FAIL]" raise "Worktree directory already exists: #{worktree_path}. Use --force to overwrite." end else puts "[OK]" end end def detect_worktree_usage print "Detecting worktree configuration... " git_dir = `git rev-parse --git-dir 2>/dev/null`.strip is_worktree = git_dir.include?('.git/worktrees/') worktree_list = `git worktree list 2>/dev/null`.strip worktree_lines = worktree_list.lines has_worktrees = worktree_lines.size > 1 parent_dir = nil if has_worktrees last_line = worktree_lines.last.strip # Parse worktree list format: "path commit [branch]" if last_line =~ /^(\S+)\s+\S+\s+\[(.+)\]$/ worktree_path = $1 branch_name = $2 # Strip branch name from path to get parent (handles branches with /) branch_path_component = branch_name.gsub('/', File::SEPARATOR) if worktree_path.end_with?(branch_path_component) parent_dir = worktree_path[0...-branch_path_component.length].chomp(File::SEPARATOR) else parent_dir = File.dirname(worktree_path) end end end info = { should_use: has_worktrees || is_worktree, parent_dir: parent_dir } puts "[OK]" if info[:parent_dir] puts " Found existing worktrees at: #{info[:parent_dir]}" end info end def should_use_worktree?(worktree_info) use_worktree = case @options[:worktree] when :yes true when :no false when :auto, nil worktree_info[:should_use] # Auto-detect from existing worktrees else raise "Invalid --worktree value: #{@options[:worktree]}" end mode_str = @options[:worktree] ? "--worktree=#{@options[:worktree]}" : "--worktree=auto" puts " Will use #{use_worktree ? 'worktree' : 'branch'} mode (#{mode_str})" puts "" use_worktree end def check_branch_existence!(use_worktree) print "Checking if branch exists... " branch_exists = system("git rev-parse --verify #{@branch_name} > /dev/null 2>&1") if branch_exists && !@options[:force] puts "[FAIL]" raise "Branch '#{@branch_name}' already exists. Use --force to overwrite." end puts "[OK]" puts " Branch does not exist" unless branch_exists puts "" end def download_and_extract_patchset url = "#{@server_url}/topics/#{@topic_id}/latest_patchset" uri = URI(url) puts "Downloading patchset from #{url}..." response = Net::HTTP.get_response(uri) unless response.is_a?(Net::HTTPSuccess) raise "Failed to download patchset (HTTP #{response.code})" end @temp_dir = Dir.mktmpdir("hackorum-patches-") io = StringIO.new(response.body) Zlib::GzipReader.wrap(io) do |gz| Gem::Package::TarReader.new(gz) do |tar| tar.each do |entry| next unless entry.file? if entry.full_name == 'hackorum.json' @metadata = JSON.parse(entry.read) verbose_log " Parsed metadata: submission_date=#{@metadata['submission_date']}" next end dest = File.join(@temp_dir, entry.full_name) File.write(dest, entry.read) puts " Extracted: #{entry.full_name}" end end end all_files = sort_patch_files(Dir.glob(File.join(@temp_dir, "*"))) categorize_and_report_files(all_files) end def extract_local_patchset puts "Extracting local patchset from #{@local_archive}..." @temp_dir = Dir.mktmpdir("hackorum-patches-") File.open(@local_archive, 'rb') do |file| Zlib::GzipReader.wrap(file) do |gz| Gem::Package::TarReader.new(gz) do |tar| tar.each do |entry| next unless entry.file? if entry.full_name == 'hackorum.json' @metadata = JSON.parse(entry.read) verbose_log " Parsed metadata: submission_date=#{@metadata['submission_date']}" next end dest = File.join(@temp_dir, entry.full_name) File.write(dest, entry.read) puts " Extracted: #{entry.full_name}" end end end end all_files = sort_patch_files(Dir.glob(File.join(@temp_dir, "*"))) categorize_and_report_files(all_files) end def categorize_and_report_files(all_files) patch_files = all_files.select { |f| f.end_with?(".patch") } diff_files = all_files.select { |f| f.end_with?(".diff") } skipped = all_files.size - patch_files.size - diff_files.size if !patch_files.empty? && !diff_files.empty? raise "Mixed patch and diff files detected. Archives must contain only .patch files OR only .diff files, not both." end if patch_files.empty? && diff_files.empty? raise "No patch or diff files found in archive" end @using_diff_mode = !diff_files.empty? files = @using_diff_mode ? diff_files : patch_files file_type = @using_diff_mode ? "diffs" : "patches" msg = "[OK] Extracted #{files.size} #{file_type}" msg += " (skipped #{skipped} other files)" if skipped > 0 puts msg puts "" files end def detect_base_commit(patch_files_dir) if @options[:base_commit] puts "Using specified base commit: #{@options[:base_commit]}" puts "" return @options[:base_commit] end patch_files = sort_patch_files(Dir.glob(File.join(patch_files_dir, "*.patch"))) diff_files = sort_patch_files(Dir.glob(File.join(patch_files_dir, "*.diff"))) all_patch_files = patch_files + diff_files return detect_default_branch_head if all_patch_files.empty? # First, check for base-commit line (from git format-patch --base) base_commit = detect_base_commit_from_base_line(all_patch_files) if base_commit puts "[OK] Base commit: #{base_commit} (from base-commit line)" puts "" return base_commit end # Fall back to index hash detection using file paths puts "Detecting base commit from patch file paths..." # Extract file paths and their "before" blob hashes from all patches # Only record the FIRST occurrence of each file - later patches in a series # will have "before" hashes that are the result of earlier patches, not the original state file_info = {} # path => before_hash all_patch_files.each do |patch_file| content = File.read(patch_file) verbose_log " Scanning #{File.basename(patch_file)} for file hashes..." # Format: diff --git a/path b/path followed by index abc123..def456 content.scan(/^diff --git a\/(.+?) b\/\1\n(?:.*?\n)*?index ([0-9a-f]+)\.\.([0-9a-f]+)/m) do |path, before_hash, after_hash| if before_hash =~ /^0+$/ verbose_log " #{path}: new file (skipped)" next end if file_info.key?(path) verbose_log " #{path}: already recorded (skipped duplicate)" next end verbose_log " #{path}: before=#{before_hash}" file_info[path] = before_hash end end if file_info.empty? puts " No modified files found in patches (all new files?)" return detect_default_branch_head end puts " Found #{file_info.size} modified files across #{all_patch_files.size} patches" if @options[:verbose] verbose_log " Files and their expected 'before' hashes:" file_info.each do |path, hash| verbose_log " #{path}: #{hash}" end end default_branch = detect_default_branch_head # Check if the "before" hashes match what's at the default branch HEAD # This confirms HEAD is the correct base default_head = `git rev-parse #{default_branch} 2>/dev/null`.strip verbose_log " Checking against #{default_branch} HEAD (#{default_head[0..11]}...):" all_match = file_info.all? do |path, before_hash| current_hash = `git rev-parse #{default_head}:#{path} 2>/dev/null`.strip matches = current_hash == before_hash || current_hash.start_with?(before_hash) || before_hash.start_with?(current_hash) if @options[:verbose] status = matches ? "MATCH" : "MISMATCH" verbose_log " #{path}:" verbose_log " patch: #{before_hash}" verbose_log " HEAD: #{current_hash.empty? ? '(not found)' : current_hash}" verbose_log " result: #{status}" end matches end if all_match puts " All file hashes match #{default_branch} HEAD" puts "[OK] Base commit: #{default_head} (#{default_branch})" puts "" return default_head end # Hashes don't match HEAD, search history for a matching commit puts " File hashes don't match HEAD, searching history..." # Find commits on the default branch that touched these files paths = file_info.keys.map { |p| Shellwords.escape(p) }.join(" ") verbose_log " Searching commits that touched: #{file_info.keys.join(', ')}" # Use submission date from metadata to constrain search until_flag = "" if @metadata && @metadata['submission_date'] submission_date = @metadata['submission_date'] verbose_log " Using submission date constraint: --until=#{submission_date}" until_flag = "--until=#{Shellwords.escape(submission_date)}" end commits = `git log #{default_branch} --pretty=format:%H #{until_flag} -n 200 -- #{paths} 2>/dev/null`.strip.split("\n") verbose_log " Found #{commits.size} candidate commits to check" commits.each_with_index do |commit, idx| next if commit.empty? verbose_log " Checking commit #{idx + 1}/#{commits.size}: #{commit[0..11]}..." mismatch_details = [] matches = file_info.all? do |path, before_hash| blob_hash = `git rev-parse #{commit}:#{path} 2>/dev/null`.strip result = blob_hash == before_hash || blob_hash.start_with?(before_hash) || before_hash.start_with?(blob_hash) unless result mismatch_details << " #{path}: patch=#{before_hash}, commit=#{blob_hash.empty? ? '(not found)' : blob_hash}" end result end if matches puts " Found matching commit: #{commit[0..11]}..." puts "[OK] Base commit: #{commit}" puts "" return commit else verbose_log " No match. Mismatches:" mismatch_details.each { |d| verbose_log d } end end puts " Could not find matching commit in history" verbose_log " Falling back to default branch HEAD" detect_default_branch_head end def detect_base_commit_from_base_line(patch_files) files_to_check = [patch_files.first, patch_files.last].uniq files_to_check.each do |patch_file| content = File.read(patch_file) if content =~ /^base-commit:\s*([0-9a-f]{40})\s*$/ base_commit = $1 puts "Found base-commit line in #{File.basename(patch_file)}: #{base_commit[0..11]}..." return base_commit end end nil end def detect_default_branch_head %w[master origin/master].each do |ref| if system("git rev-parse --verify #{ref} > /dev/null 2>&1") return ref end end 'HEAD' end def determine_worktree_path(worktree_info) return @options[:worktree_path] if @options[:worktree_path] if worktree_info[:parent_dir] File.join(worktree_info[:parent_dir], @branch_name) else repo_root = `git rev-parse --show-toplevel`.strip File.join(File.dirname(repo_root), @branch_name) end end def create_worktree(base_commit, worktree_path) print "Creating worktree at: #{worktree_path}... " if @options[:force] if File.exist?(worktree_path) system("git worktree remove #{worktree_path} --force > /dev/null 2>&1") end if system("git rev-parse --verify #{@branch_name} > /dev/null 2>&1") system("git branch -D #{@branch_name} > /dev/null 2>&1") end end parent_dir = File.dirname(worktree_path) FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir) unless system("git worktree add #{worktree_path} -b #{@branch_name} #{base_commit} > /dev/null 2>&1") puts "[FAIL]" raise "Failed to create worktree" end puts "[OK]" puts "" end def create_branch(base_commit) print "Creating branch #{@branch_name}... " if @options[:force] system("git branch -D #{@branch_name} > /dev/null 2>&1") end unless system("git checkout -b #{@branch_name} #{base_commit} > /dev/null 2>&1") puts "[FAIL]" raise "Failed to create branch" end puts "[OK]" puts "" end def apply_patches(patch_files) puts "Applying #{patch_files.size} patches with git am..." puts "" patch_files.each_with_index do |patch_file, idx| puts " [#{idx + 1}/#{patch_files.size}] #{File.basename(patch_file)}" end puts "" patch_list = patch_files.map { |f| Shellwords.escape(f) }.join(" ") output = `git am --3way #{patch_list} 2>&1` success = $?.success? output.each_line { |line| puts " #{line}" } unless success puts "" puts "[FAIL] Patch application failed" puts "" puts "You can resolve conflicts and continue with:" puts " cd #{Dir.pwd}" puts " git am --continue" puts "" puts "Or abort with:" puts " git am --abort" raise "Patch application failed" end puts "" puts "[OK] All patches applied successfully" puts "" end def apply_diffs(diff_files) puts "Applying #{diff_files.size} diff files..." puts "" diff_files.each_with_index do |diff_file, idx| basename = File.basename(diff_file, ".diff") puts " [#{idx + 1}/#{diff_files.size}] #{File.basename(diff_file)}" # Apply the diff output = `git apply --3way #{Shellwords.escape(diff_file)} 2>&1` success = $?.success? output.each_line { |line| puts " #{line}" } unless output.strip.empty? unless success puts "" puts "[FAIL] Failed to apply #{File.basename(diff_file)}" puts "" puts "You can resolve conflicts manually and retry." raise "Diff application failed" end # Stage all changes unless system("git add -A > /dev/null 2>&1") raise "Failed to stage changes for #{File.basename(diff_file)}" end # Commit with simple message commit_msg = "Apply #{basename}" unless system("git commit -m #{Shellwords.escape(commit_msg)} > /dev/null 2>&1") # Check if there's actually anything to commit status = `git status --porcelain`.strip if status.empty? puts " (no changes to commit)" else raise "Failed to commit #{File.basename(diff_file)}" end else puts " Committed: #{commit_msg}" end end puts "" puts "[OK] All diffs applied and committed successfully" puts "" end def cleanup_temp_files FileUtils.rm_rf(@temp_dir) if @temp_dir && File.exist?(@temp_dir) end def copy_files_to_git_dir(patch_files) hackorum_dir = File.join(Dir.pwd, '.hackorum') print "Copying patch files to #{hackorum_dir}... " FileUtils.rm_rf(hackorum_dir) if File.exist?(hackorum_dir) FileUtils.mkdir_p(hackorum_dir) # Copy all patch/diff files patch_files.each do |patch_file| dest = File.join(hackorum_dir, File.basename(patch_file)) FileUtils.cp(patch_file, dest) end # Write metadata if available if @metadata metadata_file = File.join(hackorum_dir, "#{@topic_id}.json") File.write(metadata_file, JSON.pretty_generate(@metadata)) end puts "[OK]" puts " Saved #{patch_files.size} files to .hackorum/" puts "" end def print_success_message(worktree_path = nil) puts "[SUCCESS]" puts "==========" puts "" puts "Branch: #{@branch_name}" if worktree_path puts "Location: #{worktree_path}" puts "" puts "Review the patches:" puts " cd #{worktree_path}" end puts "" puts "Original patch files and metadata saved to: #{File.join(Dir.pwd, '.hackorum')}" puts "" end end if __FILE__ == $0 begin HackorumPatch.new(ARGV).run rescue Interrupt puts "\n\nInterrupted by user" exit 130 rescue => e puts "\nERROR: #{e.message}" exit 1 end end