diff --git a/bin/hack b/bin/hack new file mode 100755 index 00000000..66c1fcbb --- /dev/null +++ b/bin/hack @@ -0,0 +1,362 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# hack: Create a worktree, start Claude Code, and manage cleanup +# Usage: +# hack [BRANCH] - Create worktree and start Claude Code session +# +# If BRANCH is not provided, it will present an fzf picker to select a branch + +require 'fileutils' +require 'open3' +require 'io/console' + +# Get the current repository root and name +def get_repo_info + repo_root, _status = Open3.capture2('git rev-parse --show-toplevel') + repo_root = repo_root.strip + repo_name = File.basename(repo_root) + + [repo_root, repo_name] +end + +# Get parent directory for worktrees (one level up from repo root) +def get_worktree_parent_dir + repo_root, _repo_name = get_repo_info + File.dirname(repo_root) +end + +# Get worktree path for a given branch +def get_worktree_path(branch) + _repo_root, repo_name = get_repo_info + parent_dir = get_worktree_parent_dir + clean_branch = get_clean_branch_name(branch) + "#{parent_dir}/#{repo_name}@#{clean_branch}" +end + +# Ensure we're in a git repository +def ensure_git_repo + unless system('git rev-parse --is-inside-work-tree > /dev/null 2>&1') + puts "Error: Not in a git repository" + exit 1 + end +end + +# Check if a branch exists (local or remote) +def branch_exists?(branch_name) + # Check local branches + local_check = system("git show-ref --verify --quiet refs/heads/#{branch_name}") + return true if local_check + + # Check remote branches + remote_check = system("git show-ref --verify --quiet refs/remotes/origin/#{branch_name}") + return true if remote_check + + false +end + +# Create a new branch +def create_branch(branch_name) + puts "Creating new branch '#{branch_name}' from current HEAD..." + + # Create the branch but don't switch to it (so we can create a worktree for it) + system("git branch '#{branch_name}'") + + unless $?.success? + puts "Failed to create branch '#{branch_name}'" + exit 1 + end + + puts "Branch '#{branch_name}' created successfully" +end + +# Pick a branch using fzf +def pick_branch + branches_cmd = "git branch --all | grep -v HEAD | sed 's/^\\s*//' | sed 's/^remotes\\/origin\\///' | sort -u" + branches, _status = Open3.capture2(branches_cmd) + + # Exit if no branches found + if branches.strip.empty? + puts "No branches found" + exit 1 + end + + # Use fzf to select a branch + selected, _status = Open3.capture2("fzf --height 40% --reverse --no-multi --prompt='Select existing branch for hack session: '", stdin_data: branches) + selected.strip +end + +# Prompt for new branch name +def prompt_new_branch_name + print "Enter new branch name: " + branch_name = STDIN.gets.chomp + + if branch_name.empty? + puts "Branch name cannot be empty" + exit 1 + end + + # Validate branch name (basic check) + if branch_name.match?(/[^a-zA-Z0-9\-_.\/]/) + puts "Invalid branch name. Use only letters, numbers, hyphens, underscores, dots, and forward slashes." + exit 1 + end + + branch_name +end + +# Ask user what they want to do +def ask_user_choice + puts "What would you like to do?" + puts "1. Create worktree for existing branch" + puts "2. Create new branch and worktree" + print "Enter choice (1 or 2): " + + choice = STDIN.gets.chomp + + case choice + when '1' + :existing_branch + when '2' + :new_branch + else + puts "Invalid choice. Please enter 1 or 2." + ask_user_choice + end +end + +# Get clean branch name for directory +def get_clean_branch_name(branch) + branch.gsub(/[^[:alnum:]-]/, '-').sub(/-*$/, '') +end + +# Check if worktree is dirty +def worktree_dirty?(path) + dirty_check_cmd = "git -C '#{path}' status --porcelain" + dirty_status, _status = Open3.capture2(dirty_check_cmd) + !dirty_status.strip.empty? +end + +# Prompt user for yes/no answer +def prompt_yes_no(question, default = 'n') + print "#{question} [y/N]: " + response = STDIN.gets.chomp.downcase + response.empty? ? (default == 'y') : (response == 'y' || response == 'yes') +end + +# Create or use existing worktree +def setup_worktree(branch, branch_created = false) + worktree_path = get_worktree_path(branch) + + created_new = false + + if Dir.exist?(worktree_path) + puts "Using existing worktree for '#{branch}' at '#{worktree_path}'" + else + puts "Creating worktree for '#{branch}' at '#{worktree_path}'..." + + # Add the worktree + system("git worktree add '#{worktree_path}' '#{branch}'") + + # Check if the command succeeded + unless $?.success? + FileUtils.rm_rf(worktree_path) + puts "Failed to create worktree" + exit 1 + end + + created_new = true + end + + [worktree_path, created_new] +end + +# Remove worktree if it's clean +def cleanup_worktree(worktree_path, branch_name) + if worktree_dirty?(worktree_path) + puts "Worktree has uncommitted changes - cannot auto-cleanup" + return false + end + + # Change to main worktree before removing the current one + repo_root, _repo_name = get_repo_info + current_dir = Dir.pwd + + # Only change directory if we're currently in the worktree being removed + if current_dir.start_with?(worktree_path) + puts "Moving to main worktree before cleanup..." + Dir.chdir(repo_root) + end + + puts "Removing worktree at '#{worktree_path}'..." + system("git worktree remove --force '#{worktree_path}'") + + if $?.success? + puts "Worktree for '#{branch_name}' removed successfully" + + # Ask if they want to delete the branch too + if prompt_yes_no("Would you also like to delete the branch '#{branch_name}'?") + puts "Deleting branch '#{branch_name}'..." + system("git branch -D '#{branch_name}'") + if $?.success? + puts "Branch '#{branch_name}' deleted successfully" + else + puts "Failed to delete branch '#{branch_name}'" + end + end + + return true + else + puts "Failed to remove worktree" + return false + end +end + +# Start Claude Code session +def start_claude_session(worktree_path) + puts "Starting Claude Code session in #{worktree_path}..." + puts "Use /exit to end the session" + puts + + # Change to worktree directory and start claude + Dir.chdir(worktree_path) + system('claude') + + # Return the exit status + $?.success? +end + +# Handle post-session cleanup +def handle_cleanup(worktree_path, branch_name, created_new) + repo_root, _repo_name = get_repo_info + + puts + puts "Claude Code session ended." + + # If worktree is dirty, inform user and ask what to do + if worktree_dirty?(worktree_path) + puts "Your worktree has uncommitted changes." + + if prompt_yes_no("Would you like to stay in this worktree to continue working?", 'y') + puts "Staying in worktree: #{worktree_path}" + return worktree_path + else + puts "Returning to main worktree (changes preserved in worktree)" + return repo_root + end + end + + # Ask if they want to remove the worktree + if prompt_yes_no("Are you done with this worktree? (This will delete it)") + if cleanup_worktree(worktree_path, branch_name) + puts "Returning to main worktree" + return repo_root + else + # Cleanup failed, ask where to go + if prompt_yes_no("Would you like to stay in this worktree?") + return worktree_path + else + return repo_root + end + end + else + # User wants to keep the worktree + if prompt_yes_no("Would you like to stay in this worktree?") + puts "Staying in worktree: #{worktree_path}" + return worktree_path + else + puts "Returning to main worktree (worktree preserved)" + return repo_root + end + end +end + +# Display usage information +def show_usage + puts "Usage: hack [BRANCH]" + puts + puts "Create a worktree and start a Claude Code session." + puts "After the session ends, you'll be prompted about cleanup." + puts + puts "If BRANCH is not provided, you'll get an fzf picker to select a branch." + exit 0 +end + +# Main function +def main + ensure_git_repo + + # Parse arguments + if ARGV.include?('--help') || ARGV.include?('-h') || ARGV.include?('help') + show_usage + return + end + + # Determine branch and whether we need to create it + branch = nil + branch_created = false + + if ARGV.empty? + # No arguments - ask user what they want to do + choice = ask_user_choice + + case choice + when :existing_branch + branch = pick_branch + when :new_branch + branch = prompt_new_branch_name + create_branch(branch) + branch_created = true + end + else + # Branch name provided as argument + branch = ARGV[0] + + unless branch_exists?(branch) + if prompt_yes_no("Branch '#{branch}' doesn't exist. Create it?", 'y') + create_branch(branch) + branch_created = true + else + puts "Exiting without creating branch" + exit 0 + end + end + end + + # Exit if no branch selected (e.g., user pressed ESC in fzf) + if branch.nil? || branch.empty? + puts "No branch selected, exiting" + exit 0 + end + + # Setup worktree + worktree_path, created_new = setup_worktree(branch, branch_created) + clean_branch = get_clean_branch_name(branch) + + # Ask if user wants to open a new editor window + if prompt_yes_no("Would you like to open a new editor window on this worktree?") + editor = ENV['EDITOR'] || 'vim' + puts "Opening editor in #{worktree_path}..." + system("#{editor} '#{worktree_path}'") + end + + # Start Claude Code session + claude_success = start_claude_session(worktree_path) + + # Handle cleanup and determine final directory + final_dir = handle_cleanup(worktree_path, branch, created_new) + + # Change to final directory and spawn new shell + if Dir.exist?(final_dir) + puts "Changing to: #{final_dir}" + Dir.chdir(final_dir) + exec(ENV['SHELL']) + else + puts "Target directory no longer exists, falling back to main worktree" + repo_root, _repo_name = get_repo_info + Dir.chdir(repo_root) + exec(ENV['SHELL']) + end +end + +main diff --git a/bin/tree b/bin/tree new file mode 100755 index 00000000..7615e5ef --- /dev/null +++ b/bin/tree @@ -0,0 +1,358 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# git-tree.rb: Manage git worktrees in Ruby +# Usage: +# git-tree add [BRANCH] - Create and switch to a worktree +# git-tree remove [BRANCH] - Remove a worktree, aborting if dirty +# git-tree list - List all worktrees +# git-tree cd [BRANCH] - Print path to a worktree (for shell integration) +# +# If BRANCH is not provided, it will present an fzf picker to select a branch + +require 'fileutils' +require 'open3' + +# Get the current repository root and name +def get_repo_info + repo_root, _status = Open3.capture2('git rev-parse --show-toplevel') + repo_root = repo_root.strip + repo_name = File.basename(repo_root) + + [repo_root, repo_name] +end + +# Get parent directory for worktrees (one level up from repo root) +def get_worktree_parent_dir + repo_root, _repo_name = get_repo_info + File.dirname(repo_root) +end + +# Get worktree path for a given branch +def get_worktree_path(branch) + _repo_root, repo_name = get_repo_info + parent_dir = get_worktree_parent_dir + clean_branch = get_clean_branch_name(branch) + "#{parent_dir}/#{repo_name}@#{clean_branch}" +end + +# Ensure we're in a git repository +def ensure_git_repo + unless system('git rev-parse --is-inside-work-tree > /dev/null 2>&1') + puts "Error: Not in a git repository" + exit 1 + end +end + +# Pick a branch using fzf +def pick_branch + branches_cmd = "git branch --all | grep -v HEAD | sed 's/^\\s*//' | sed 's/^remotes\\/origin\\///' | sort -u" + branches, _status = Open3.capture2(branches_cmd) + + # Exit if no branches found + if branches.strip.empty? + puts "No branches found" + exit 1 + end + + # Use fzf to select a branch + selected, _status = Open3.capture2("fzf --height 40% --reverse --no-multi", stdin_data: branches) + selected.strip +end + +# Pick a worktree using fzf +def pick_worktree + parent_dir = get_worktree_parent_dir + _repo_root, repo_name = get_repo_info + + # Find worktrees with the repo@branch pattern + find_cmd = "find #{parent_dir} -maxdepth 1 -name '#{repo_name}@*' -type d" + worktrees_paths, _status = Open3.capture2(find_cmd) + + # Format worktrees (extract branch names) + worktrees = worktrees_paths.lines.map do |path| + basename = File.basename(path.strip) + basename.sub(/^#{Regexp.escape(repo_name)}@/, '') + end + + # Check if we have any worktrees to remove + if worktrees.empty? + puts "No worktrees found with pattern #{repo_name}@*" + exit 0 + end + + # Use fzf to select a worktree + selected, _status = Open3.capture2("fzf --height 40% --reverse --no-multi --prompt='Select worktree to remove: '", + stdin_data: worktrees.join("\n")) + selected.strip +end + +# Get clean branch name for directory +def get_clean_branch_name(branch) + branch.gsub(/[^[:alnum:]-]/, '-').sub(/-*$/, '') +end + +# Command: Add a new worktree +def cmd_add(args) + branch = args.empty? ? pick_branch : args[0] + + # Exit if no branch selected (e.g., user pressed ESC in fzf) + exit 0 if branch.empty? + + worktree_path = get_worktree_path(branch) + + if Dir.exist?(worktree_path) + puts "Worktree for '#{branch}' already exists at '#{worktree_path}'" + else + puts "Creating worktree for '#{branch}' at '#{worktree_path}'..." + + # Add the worktree + system("git worktree add '#{worktree_path}' '#{branch}'") + + # Check if the command succeeded + unless $?.success? + FileUtils.rm_rf(worktree_path) + puts "Failed to create worktree" + exit 1 + end + end + + # Change to the worktree directory + puts "Changing to worktree directory: #{worktree_path}" + Dir.chdir(worktree_path) + exec(ENV['SHELL']) +end + +# Command: Remove a worktree +def cmd_remove(args) + branch = if args.empty? + pick_worktree + else + args[0] + end + + # Exit if no worktree selected + exit 0 if branch.empty? + + worktree_path = get_worktree_path(branch) + + unless Dir.exist?(worktree_path) + puts "Error: Worktree not found at '#{worktree_path}'" + exit 1 + end + + # Check if worktree is dirty + dirty_check_cmd = "git -C '#{worktree_path}' status --porcelain" + dirty_status, _status = Open3.capture2(dirty_check_cmd) + + if !dirty_status.strip.empty? + puts "Error: Worktree at '#{worktree_path}' has uncommitted changes. Aborting." + exit 1 + end + + # Check if we're currently in the worktree being removed + current_dir = Dir.pwd + in_target_worktree = current_dir.start_with?(worktree_path) + + if in_target_worktree + puts "Currently in the worktree being removed. Will switch to main worktree afterward." + end + + puts "Removing worktree at '#{worktree_path}'..." + system("git worktree remove --force '#{worktree_path}'") + + puts "Worktree removed successfully." + + # If we were in the removed worktree, switch to main worktree + if in_target_worktree + repo_root, _repo_name = get_repo_info + puts "Changing to main worktree directory: #{repo_root}" + Dir.chdir(repo_root) + exec(ENV['SHELL']) + end +end + +# Command: List all worktrees +def cmd_list(_args) + repo_root, repo_name = get_repo_info + parent_dir = get_worktree_parent_dir + + puts "Git worktrees:" + puts "---------------------------" + + # Get current directory to check if we're in a worktree + current_dir = Dir.pwd + + # List the main worktree first + git_branch_cmd = "git -C '#{repo_root}' rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'Unknown'" + git_branch, _status = Open3.capture2(git_branch_cmd) + git_branch = git_branch.strip + + # Check if main worktree has uncommitted changes + dirty_check_cmd = "git -C '#{repo_root}' status --porcelain 2>/dev/null" + dirty_status, _status = Open3.capture2(dirty_check_cmd) + status = dirty_status.strip.empty? ? "[clean]" : "[dirty]" + + # Add a marker if this is the current worktree + current_indicator = current_dir.start_with?(repo_root) && current_dir == repo_root ? "* " : "" + + printf("%s%-40s %-30s %s\n", current_indicator, "main (main worktree)", git_branch, status) + + # Find additional worktrees with the repo@branch pattern + find_cmd = "find #{parent_dir} -maxdepth 1 -name '#{repo_name}@*' -type d" + worktrees_paths, _status = Open3.capture2(find_cmd) + + if !worktrees_paths.strip.empty? + worktrees = worktrees_paths.lines.map do |path| + path.strip + end.sort + + worktrees.each do |path| + basename = File.basename(path) + branch_name = basename.sub(/^#{Regexp.escape(repo_name)}@/, '') + + # Get git branch for this worktree + git_branch_cmd = "git -C '#{path}' rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'Unknown'" + git_branch, _status = Open3.capture2(git_branch_cmd) + git_branch = git_branch.strip + + # Check if worktree has uncommitted changes + dirty_check_cmd = "git -C '#{path}' status --porcelain 2>/dev/null" + dirty_status, _status = Open3.capture2(dirty_check_cmd) + status = dirty_status.strip.empty? ? "[clean]" : "[dirty]" + + # Add a marker if this is the current worktree + current_indicator = "" + if current_dir.start_with?(path) + current_indicator = "* " + end + + printf("%s%-40s %-30s %s\n", current_indicator, branch_name, git_branch, status) + end + end +end + +# Command: Change to a worktree directory +def cmd_cd(args) + repo_root, repo_name = get_repo_info + parent_dir = get_worktree_parent_dir + + # If a branch name is provided, use it; otherwise pick using fzf + if args.empty? + # Collect all worktrees (main + additional ones) + worktrees = [{ name: "main", path: repo_root }] + + # Find additional worktrees with the repo@branch pattern + find_cmd = "find #{parent_dir} -maxdepth 1 -name '#{repo_name}@*' -type d" + worktrees_paths, _status = Open3.capture2(find_cmd) + + if !worktrees_paths.strip.empty? + additional_worktrees = worktrees_paths.lines.map do |path| + path = path.strip + basename = File.basename(path) + branch_name = basename.sub(/^#{Regexp.escape(repo_name)}@/, '') + { name: branch_name, path: path } + end.sort_by { |w| w[:name] } + + worktrees.concat(additional_worktrees) + end + + # Format the choices for fzf + formatted_worktrees = worktrees.map do |worktree| + path = worktree[:path] + name = worktree[:name] + + # Get git branch for this worktree + git_branch_cmd = "git -C '#{path}' rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'Unknown'" + git_branch, _status = Open3.capture2(git_branch_cmd) + git_branch = git_branch.strip + + # Display string for fzf with original path stored for retrieval + display_name = name == "main" ? "#{name} (main worktree)" : name + "#{display_name} (#{git_branch})\t#{path}" + end.join("\n") + + # Exit if no worktrees available + if formatted_worktrees.empty? + puts "No worktrees found" + exit 1 + end + + # Use fzf to select a worktree + selected, _status = Open3.capture2("fzf --height 40% --reverse --no-multi --with-nth=1 -d'\t' --prompt='Select worktree: '", + stdin_data: formatted_worktrees) + + # Exit if nothing selected (e.g., ESC pressed) + exit 0 if selected.strip.empty? + + # Extract the path from the selected line + worktree_path = selected.strip.split("\t")[1] + else + # Get branch name from arguments + branch_name = args[0] + + if branch_name == "main" + worktree_path = repo_root + else + worktree_path = get_worktree_path(branch_name) + + # Check if the worktree exists + unless Dir.exist?(worktree_path) + puts "Error: Worktree not found at '#{worktree_path}'" + exit 1 + end + end + end + + # Change to the worktree directory + puts "Changing to worktree directory: #{worktree_path}" + Dir.chdir(worktree_path) + exec(ENV['SHELL']) +end + +# Display usage information +def show_usage + puts "Usage: git-tree [COMMAND] [BRANCH]" + puts + puts "Commands:" + puts " add - Create and switch to a worktree" + puts " remove - Remove a worktree, aborting if dirty" + puts " list - List all worktrees" + puts " cd - Change to a worktree directory" + puts + puts "If BRANCH is not provided, it will present an fzf picker" + exit 0 +end + +# Main function +def main + ensure_git_repo + + # No default command - show usage if no command provided + if ARGV.empty? + show_usage + return + end + + # Parse command + command = ARGV.shift + + # Execute the appropriate command + case command + when "add" + cmd_add(ARGV) + when "remove" + cmd_remove(ARGV) + when "list" + cmd_list(ARGV) + when "cd" + cmd_cd(ARGV) + when "help", "--help", "-h" + show_usage + else + puts "Unknown command: #{command}" + show_usage + end +end + +main \ No newline at end of file