Skip to content

Commit aca8521

Browse files
committed
Add hack and tree commands
1 parent 50a3c94 commit aca8521

File tree

2 files changed

+713
-0
lines changed

2 files changed

+713
-0
lines changed

bin/hack

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# hack: Create a worktree, start Claude Code, and manage cleanup
5+
# Usage:
6+
# hack [BRANCH] - Create worktree and start Claude Code session
7+
#
8+
# If BRANCH is not provided, it will present an fzf picker to select a branch
9+
10+
require 'fileutils'
11+
require 'open3'
12+
require 'io/console'
13+
14+
# Get the current repository root and name
15+
def get_repo_info
16+
repo_root, _status = Open3.capture2('git rev-parse --show-toplevel')
17+
repo_root = repo_root.strip
18+
repo_name = File.basename(repo_root)
19+
20+
[repo_root, repo_name]
21+
end
22+
23+
# Get parent directory for worktrees (one level up from repo root)
24+
def get_worktree_parent_dir
25+
repo_root, _repo_name = get_repo_info
26+
File.dirname(repo_root)
27+
end
28+
29+
# Get worktree path for a given branch
30+
def get_worktree_path(branch)
31+
_repo_root, repo_name = get_repo_info
32+
parent_dir = get_worktree_parent_dir
33+
clean_branch = get_clean_branch_name(branch)
34+
"#{parent_dir}/#{repo_name}@#{clean_branch}"
35+
end
36+
37+
# Ensure we're in a git repository
38+
def ensure_git_repo
39+
unless system('git rev-parse --is-inside-work-tree > /dev/null 2>&1')
40+
puts "Error: Not in a git repository"
41+
exit 1
42+
end
43+
end
44+
45+
# Check if a branch exists (local or remote)
46+
def branch_exists?(branch_name)
47+
# Check local branches
48+
local_check = system("git show-ref --verify --quiet refs/heads/#{branch_name}")
49+
return true if local_check
50+
51+
# Check remote branches
52+
remote_check = system("git show-ref --verify --quiet refs/remotes/origin/#{branch_name}")
53+
return true if remote_check
54+
55+
false
56+
end
57+
58+
# Create a new branch
59+
def create_branch(branch_name)
60+
puts "Creating new branch '#{branch_name}' from current HEAD..."
61+
62+
# Create the branch but don't switch to it (so we can create a worktree for it)
63+
system("git branch '#{branch_name}'")
64+
65+
unless $?.success?
66+
puts "Failed to create branch '#{branch_name}'"
67+
exit 1
68+
end
69+
70+
puts "Branch '#{branch_name}' created successfully"
71+
end
72+
73+
# Pick a branch using fzf
74+
def pick_branch
75+
branches_cmd = "git branch --all | grep -v HEAD | sed 's/^\\s*//' | sed 's/^remotes\\/origin\\///' | sort -u"
76+
branches, _status = Open3.capture2(branches_cmd)
77+
78+
# Exit if no branches found
79+
if branches.strip.empty?
80+
puts "No branches found"
81+
exit 1
82+
end
83+
84+
# Use fzf to select a branch
85+
selected, _status = Open3.capture2("fzf --height 40% --reverse --no-multi --prompt='Select existing branch for hack session: '", stdin_data: branches)
86+
selected.strip
87+
end
88+
89+
# Prompt for new branch name
90+
def prompt_new_branch_name
91+
print "Enter new branch name: "
92+
branch_name = STDIN.gets.chomp
93+
94+
if branch_name.empty?
95+
puts "Branch name cannot be empty"
96+
exit 1
97+
end
98+
99+
# Validate branch name (basic check)
100+
if branch_name.match?(/[^a-zA-Z0-9\-_.\/]/)
101+
puts "Invalid branch name. Use only letters, numbers, hyphens, underscores, dots, and forward slashes."
102+
exit 1
103+
end
104+
105+
branch_name
106+
end
107+
108+
# Ask user what they want to do
109+
def ask_user_choice
110+
puts "What would you like to do?"
111+
puts "1. Create worktree for existing branch"
112+
puts "2. Create new branch and worktree"
113+
print "Enter choice (1 or 2): "
114+
115+
choice = STDIN.gets.chomp
116+
117+
case choice
118+
when '1'
119+
:existing_branch
120+
when '2'
121+
:new_branch
122+
else
123+
puts "Invalid choice. Please enter 1 or 2."
124+
ask_user_choice
125+
end
126+
end
127+
128+
# Get clean branch name for directory
129+
def get_clean_branch_name(branch)
130+
branch.gsub(/[^[:alnum:]-]/, '-').sub(/-*$/, '')
131+
end
132+
133+
# Check if worktree is dirty
134+
def worktree_dirty?(path)
135+
dirty_check_cmd = "git -C '#{path}' status --porcelain"
136+
dirty_status, _status = Open3.capture2(dirty_check_cmd)
137+
!dirty_status.strip.empty?
138+
end
139+
140+
# Prompt user for yes/no answer
141+
def prompt_yes_no(question, default = 'n')
142+
print "#{question} [y/N]: "
143+
response = STDIN.gets.chomp.downcase
144+
response.empty? ? (default == 'y') : (response == 'y' || response == 'yes')
145+
end
146+
147+
# Create or use existing worktree
148+
def setup_worktree(branch, branch_created = false)
149+
worktree_path = get_worktree_path(branch)
150+
151+
created_new = false
152+
153+
if Dir.exist?(worktree_path)
154+
puts "Using existing worktree for '#{branch}' at '#{worktree_path}'"
155+
else
156+
puts "Creating worktree for '#{branch}' at '#{worktree_path}'..."
157+
158+
# Add the worktree
159+
system("git worktree add '#{worktree_path}' '#{branch}'")
160+
161+
# Check if the command succeeded
162+
unless $?.success?
163+
FileUtils.rm_rf(worktree_path)
164+
puts "Failed to create worktree"
165+
exit 1
166+
end
167+
168+
created_new = true
169+
end
170+
171+
[worktree_path, created_new]
172+
end
173+
174+
# Remove worktree if it's clean
175+
def cleanup_worktree(worktree_path, branch_name)
176+
if worktree_dirty?(worktree_path)
177+
puts "Worktree has uncommitted changes - cannot auto-cleanup"
178+
return false
179+
end
180+
181+
# Change to main worktree before removing the current one
182+
repo_root, _repo_name = get_repo_info
183+
current_dir = Dir.pwd
184+
185+
# Only change directory if we're currently in the worktree being removed
186+
if current_dir.start_with?(worktree_path)
187+
puts "Moving to main worktree before cleanup..."
188+
Dir.chdir(repo_root)
189+
end
190+
191+
puts "Removing worktree at '#{worktree_path}'..."
192+
system("git worktree remove --force '#{worktree_path}'")
193+
194+
if $?.success?
195+
puts "Worktree for '#{branch_name}' removed successfully"
196+
197+
# Ask if they want to delete the branch too
198+
if prompt_yes_no("Would you also like to delete the branch '#{branch_name}'?")
199+
puts "Deleting branch '#{branch_name}'..."
200+
system("git branch -D '#{branch_name}'")
201+
if $?.success?
202+
puts "Branch '#{branch_name}' deleted successfully"
203+
else
204+
puts "Failed to delete branch '#{branch_name}'"
205+
end
206+
end
207+
208+
return true
209+
else
210+
puts "Failed to remove worktree"
211+
return false
212+
end
213+
end
214+
215+
# Start Claude Code session
216+
def start_claude_session(worktree_path)
217+
puts "Starting Claude Code session in #{worktree_path}..."
218+
puts "Use /exit to end the session"
219+
puts
220+
221+
# Change to worktree directory and start claude
222+
Dir.chdir(worktree_path)
223+
system('claude')
224+
225+
# Return the exit status
226+
$?.success?
227+
end
228+
229+
# Handle post-session cleanup
230+
def handle_cleanup(worktree_path, branch_name, created_new)
231+
repo_root, _repo_name = get_repo_info
232+
233+
puts
234+
puts "Claude Code session ended."
235+
236+
# If worktree is dirty, inform user and ask what to do
237+
if worktree_dirty?(worktree_path)
238+
puts "Your worktree has uncommitted changes."
239+
240+
if prompt_yes_no("Would you like to stay in this worktree to continue working?", 'y')
241+
puts "Staying in worktree: #{worktree_path}"
242+
return worktree_path
243+
else
244+
puts "Returning to main worktree (changes preserved in worktree)"
245+
return repo_root
246+
end
247+
end
248+
249+
# Ask if they want to remove the worktree
250+
if prompt_yes_no("Are you done with this worktree? (This will delete it)")
251+
if cleanup_worktree(worktree_path, branch_name)
252+
puts "Returning to main worktree"
253+
return repo_root
254+
else
255+
# Cleanup failed, ask where to go
256+
if prompt_yes_no("Would you like to stay in this worktree?")
257+
return worktree_path
258+
else
259+
return repo_root
260+
end
261+
end
262+
else
263+
# User wants to keep the worktree
264+
if prompt_yes_no("Would you like to stay in this worktree?")
265+
puts "Staying in worktree: #{worktree_path}"
266+
return worktree_path
267+
else
268+
puts "Returning to main worktree (worktree preserved)"
269+
return repo_root
270+
end
271+
end
272+
end
273+
274+
# Display usage information
275+
def show_usage
276+
puts "Usage: hack [BRANCH]"
277+
puts
278+
puts "Create a worktree and start a Claude Code session."
279+
puts "After the session ends, you'll be prompted about cleanup."
280+
puts
281+
puts "If BRANCH is not provided, you'll get an fzf picker to select a branch."
282+
exit 0
283+
end
284+
285+
# Main function
286+
def main
287+
ensure_git_repo
288+
289+
# Parse arguments
290+
if ARGV.include?('--help') || ARGV.include?('-h') || ARGV.include?('help')
291+
show_usage
292+
return
293+
end
294+
295+
# Determine branch and whether we need to create it
296+
branch = nil
297+
branch_created = false
298+
299+
if ARGV.empty?
300+
# No arguments - ask user what they want to do
301+
choice = ask_user_choice
302+
303+
case choice
304+
when :existing_branch
305+
branch = pick_branch
306+
when :new_branch
307+
branch = prompt_new_branch_name
308+
create_branch(branch)
309+
branch_created = true
310+
end
311+
else
312+
# Branch name provided as argument
313+
branch = ARGV[0]
314+
315+
unless branch_exists?(branch)
316+
if prompt_yes_no("Branch '#{branch}' doesn't exist. Create it?", 'y')
317+
create_branch(branch)
318+
branch_created = true
319+
else
320+
puts "Exiting without creating branch"
321+
exit 0
322+
end
323+
end
324+
end
325+
326+
# Exit if no branch selected (e.g., user pressed ESC in fzf)
327+
if branch.nil? || branch.empty?
328+
puts "No branch selected, exiting"
329+
exit 0
330+
end
331+
332+
# Setup worktree
333+
worktree_path, created_new = setup_worktree(branch, branch_created)
334+
clean_branch = get_clean_branch_name(branch)
335+
336+
# Start Claude Code session
337+
claude_success = start_claude_session(worktree_path)
338+
339+
# Handle cleanup and determine final directory
340+
final_dir = handle_cleanup(worktree_path, branch, created_new)
341+
342+
# Change to final directory and spawn new shell
343+
if Dir.exist?(final_dir)
344+
puts "Changing to: #{final_dir}"
345+
Dir.chdir(final_dir)
346+
exec(ENV['SHELL'])
347+
else
348+
puts "Target directory no longer exists, falling back to main worktree"
349+
repo_root, _repo_name = get_repo_info
350+
Dir.chdir(repo_root)
351+
exec(ENV['SHELL'])
352+
end
353+
end
354+
355+
main

0 commit comments

Comments
 (0)