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