hiiro 0.1.47 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/h-otask DELETED
@@ -1,867 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "hiiro"
4
- require "fileutils"
5
- require "yaml"
6
- require "pathname"
7
-
8
- hiiro = Hiiro.init(*ARGV, plugins: [Tmux])
9
-
10
- class TaskManager
11
- attr_reader :hiiro
12
-
13
- def initialize(hiiro)
14
- @hiiro = hiiro
15
- end
16
-
17
- def help
18
- puts "Usage: h task <subcommand> [args]"
19
- puts
20
- puts "Subcommands:"
21
- puts " list, ls List all worktrees and their active tasks"
22
- puts " start TASK [APP] Start a task (reuses available worktree or creates new)"
23
- puts " switch [TASK] Switch to an existing task (interactive if no task given)"
24
- puts " app APP_NAME Open a tmux window for an app in current worktree"
25
- puts " apps List configured apps from apps.yml"
26
- puts " save Save current tmux session info for this task"
27
- puts " status, st Show current task status"
28
- puts " stop Stop working on current task (worktree becomes available)"
29
- end
30
-
31
- # List all worktrees and their active tasks
32
- def list_trees
33
- puts "Git worktrees:"
34
- puts
35
-
36
- if trees.empty?
37
- puts " (no worktrees found)"
38
- puts
39
- puts " Start a task with 'h task start TASK_NAME' to create one."
40
- return
41
- end
42
-
43
- current = current_task
44
- active, available = trees.partition { |tree_name| task_for_tree(tree_name) }
45
- sessions = tmux_sessions
46
-
47
- # Group active trees by parent task
48
- groups = {}
49
- active.each do |tree_name|
50
- task = task_for_tree(tree_name)
51
- parent = task.include?('/') ? task.split('/').first : task
52
- groups[parent] ||= []
53
- groups[parent] << { tree: tree_name, task: task }
54
- end
55
-
56
- first_group = true
57
- groups.each do |parent, entries|
58
- puts unless first_group
59
- first_group = false
60
-
61
- # Sort so the main entry (parent itself or parent/main) comes first
62
- entries.sort_by! { |e| e[:task] == parent || e[:task].end_with?('/main') ? 0 : 1 }
63
-
64
- entries.each_with_index do |entry, i|
65
- tree_name = entry[:tree]
66
- task = entry[:task]
67
- marker = (current && current[:tree] == tree_name) ? "*" : " "
68
- branch = worktree_branch(tree_name)
69
- branch_str = branch ? " [#{branch}]" : ""
70
-
71
- # Check if there's a tmux session for this task
72
- session_name = session_name_for(task)
73
- has_session = sessions.include?(session_name)
74
- session_marker = has_session ? "+" : " "
75
-
76
- if i == 0
77
- # Parent task line
78
- display_name = parent
79
- puts format("%s%s %s%s", marker, session_marker, display_name, branch_str)
80
- else
81
- # Subtask line: align /child_name under the parent name
82
- child_name = task.include?('/') ? task.split('/', 2).last : task
83
- padding = " " * parent.length
84
- puts format("%s%s %s/%s%s", marker, session_marker, padding, child_name, branch_str)
85
- end
86
- end
87
- end
88
-
89
- if available.any?
90
- puts
91
- available.each do |tree_name|
92
- branch = worktree_branch(tree_name)
93
- branch_str = branch ? " [#{branch}]" : ""
94
- puts format(" %-20s (available)%s", tree_name, branch_str)
95
- end
96
- end
97
-
98
- # List tmux sessions without associated tasks
99
- associated_sessions = active.map { |tree_name| session_name_for(task_for_tree(tree_name)) }
100
- unassociated_sessions = sessions - associated_sessions
101
-
102
- if unassociated_sessions.any?
103
- puts
104
- puts "Tmux sessions without tasks:"
105
- unassociated_sessions.each do |session|
106
- puts format(" %s", session)
107
- end
108
- end
109
- end
110
-
111
- # Start working on a task
112
- def start_task(task_name, app: nil)
113
- # Check if task already exists as a worktree
114
- existing_tree = tree_for_task(task_name)
115
- if existing_tree
116
- puts "Task '#{task_name}' already active in tree '#{existing_tree}'"
117
- puts "Switching to existing session..."
118
- switch_to_task(task_name, existing_tree, app: app)
119
- return true
120
- end
121
-
122
- # Find an available worktree to reuse, or create a new one
123
- available_tree = find_available_tree
124
-
125
- # New tasks get a nested structure: task_name/main
126
- # so subtasks can live alongside as task_name/subtask_name
127
- subtree_name = "#{task_name}/main"
128
-
129
- if available_tree
130
- # Rename the available worktree to the task's main subtree
131
- old_path = tree_path(available_tree)
132
- new_path = File.join(Dir.home, 'work', subtree_name)
133
-
134
- if available_tree != subtree_name
135
- puts "Renaming worktree '#{available_tree}' to '#{subtree_name}'..."
136
- FileUtils.mkdir_p(File.dirname(new_path))
137
- result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
138
- unless result
139
- puts "ERROR: Failed to rename worktree"
140
- return false
141
- end
142
- clear_worktree_cache
143
- end
144
-
145
- final_tree_name = subtree_name
146
- final_tree_path = new_path
147
- else
148
- # No available worktree, create a new one
149
- puts "Creating new worktree for '#{task_name}'..."
150
- new_path = File.join(Dir.home, 'work', subtree_name)
151
-
152
- # Create worktree from main branch (detached to avoid branch conflicts)
153
- FileUtils.mkdir_p(File.dirname(new_path))
154
- result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
155
- unless result
156
- puts "ERROR: Failed to create worktree"
157
- return false
158
- end
159
- clear_worktree_cache
160
-
161
- final_tree_name = subtree_name
162
- final_tree_path = new_path
163
- end
164
-
165
- # Associate task with tree
166
- assign_task_to_tree(task_name, final_tree_name)
167
-
168
- # Create/switch to tmux session
169
- session_name = session_name_for(task_name)
170
-
171
- # Determine base path (app path if specified, otherwise tree root)
172
- base_path = final_tree_path
173
- if app
174
- result = find_app_path(final_tree_name, app)
175
- case result
176
- in nil
177
- puts "WARNING: App '#{app}' not found, using tree root"
178
- in [:ambiguous, matches]
179
- puts "WARNING: '#{app}' matches multiple apps: #{matches.join(', ')}"
180
- puts "Using tree root instead"
181
- in [resolved_name, app_path]
182
- base_path = app_path
183
- puts "Using app '#{resolved_name}' as base path"
184
- end
185
- end
186
-
187
- Dir.chdir(base_path)
188
- hiiro.start_tmux_session(session_name)
189
-
190
- save_task_metadata(task_name, tree: final_tree_name, session: session_name, app: app)
191
-
192
- puts "Started task '#{task_name}' in worktree '#{final_tree_name}'"
193
- true
194
- end
195
-
196
- # Start working on a task
197
- def switch_task(task_name = nil)
198
- # If no task name provided, use interactive selection
199
- if task_name.nil? || task_name.empty?
200
- task_name = select_task_interactive
201
- return false unless task_name
202
- end
203
-
204
- tree, task = assignments.find { |tree, task| task.start_with?(task_name) } || []
205
-
206
- unless task
207
- puts "No task matching #{task_name} found."
208
- return false
209
- end
210
-
211
- switch_to_task(task, tree)
212
-
213
- puts "Started task '#{task}' in tree '#{tree}'"
214
- true
215
- end
216
-
217
- # Interactive task selection using sk
218
- def select_task_interactive
219
- active_tasks = trees.select { |tree_name| task_for_tree(tree_name) }
220
-
221
- if active_tasks.empty?
222
- puts "No active tasks found."
223
- return nil
224
- end
225
-
226
- # Build selection lines
227
- lines = active_tasks.map do |tree_name|
228
- task = task_for_tree(tree_name)
229
- "#{tree_name.ljust(20)} => #{task}"
230
- end
231
-
232
- selected = Hiiro::Sk.select(lines)
233
- return nil unless selected
234
-
235
- # Parse the selected line to extract the task name
236
- if selected =~ /=>\s*(\S+)/
237
- return $1
238
- end
239
-
240
- nil
241
- end
242
-
243
- # Open an app window within the current tree
244
- def open_app(app_name)
245
- current = current_task
246
- unless current
247
- puts "ERROR: Not currently in a task session"
248
- puts "Use 'h task start TASK_NAME' first"
249
- return false
250
- end
251
-
252
- tree = current[:tree]
253
- result = find_app_path(tree, app_name)
254
-
255
- case result
256
- in nil
257
- puts "ERROR: App '#{app_name}' not found"
258
- puts
259
- list_apps(tree)
260
- return false
261
- in [:ambiguous, matches]
262
- puts "ERROR: '#{app_name}' matches multiple apps:"
263
- matches.each { |m| puts " #{m}" }
264
- puts
265
- puts "Be more specific."
266
- return false
267
- in [resolved_name, app_path]
268
- # Create new tmux window with app directory as base
269
- system('tmux', 'new-window', '-n', resolved_name, '-c', app_path)
270
- puts "Opened '#{resolved_name}' in new window (#{app_path})"
271
- true
272
- end
273
- end
274
-
275
- # Open an app window within the current tree
276
- def cd_app(app_name=nil)
277
- current = current_task
278
- unless current
279
- puts "ERROR: Not currently in a task session"
280
- puts "Use 'h task start TASK_NAME' first"
281
- return false
282
- end
283
-
284
- tree = current[:tree]
285
-
286
- result = []
287
- if app_name.to_s == ''
288
- result = ['root', tree_path(tree)]
289
- else
290
- result = find_app_path(tree, app_name)
291
- end
292
-
293
- case result
294
- in nil
295
- puts "ERROR: App '#{app_name}' not found"
296
- puts
297
- list_apps(tree)
298
- return false
299
- in [:ambiguous, matches]
300
- puts "ERROR: '#{app_name}' matches multiple apps:"
301
- matches.each { |m| puts " #{m}" }
302
- puts
303
- puts "Be more specific."
304
- return false
305
- in [resolved_name, app_path]
306
- # Create new tmux window with app directory as base
307
- pane = ENV['TMUX_PANE']
308
- if pane
309
- puts "PANE: #{pane}"
310
- puts command: ['tmux', 'send-keys', '-t', pane, "cd #{app_path}\n"].join(' ')
311
- system('tmux', 'send-keys', '-t', pane, "cd #{app_path}\n")
312
- else
313
- puts command: ['tmux', 'send-keys', "cd #{app_path}\n"].join(' ')
314
- system('tmux', 'send-keys', "cd #{app_path}\n")
315
- end
316
- puts "Opened '#{resolved_name}' in new window (#{app_path})"
317
- true
318
- end
319
- end
320
-
321
- # Save current tmux session state
322
- def save_current
323
- current = current_task
324
- unless current
325
- puts "ERROR: Not currently in a task session"
326
- return false
327
- end
328
-
329
- task_name = current[:task]
330
- tree = current[:tree]
331
- session = current[:session]
332
-
333
- # Capture tmux window info
334
- windows = capture_tmux_windows(session)
335
-
336
- save_task_metadata(task_name,
337
- tree: tree,
338
- session: session,
339
- windows: windows,
340
- saved_at: Time.now.iso8601
341
- )
342
-
343
- puts "Saved task '#{task_name}' state (#{windows.count} windows)"
344
- true
345
- end
346
-
347
- # Show current task status
348
- def status
349
- current = current_task
350
- unless current
351
- puts "Not currently in a task session"
352
- return
353
- end
354
-
355
- puts "Current task: #{current[:task]}"
356
- puts "Worktree: #{current[:tree]}"
357
- puts "Path: #{tree_path(current[:tree])}"
358
- puts "Session: #{current[:session]}"
359
-
360
- meta = task_metadata(current[:task])
361
- if meta && meta['saved_at']
362
- puts "Last saved: #{meta['saved_at']}"
363
- end
364
- end
365
-
366
- # Stop working on current task (disassociate from worktree)
367
- def stop_current
368
- current = current_task
369
- unless current
370
- puts "Not currently in a task session"
371
- return false
372
- end
373
-
374
- stop_task(current[:task])
375
- end
376
-
377
- # Stop working on a task (disassociate from worktree)
378
- def stop_task(task_name)
379
- tree = tree_for_task(task_name)
380
- task_name = task_for_tree(tree)
381
-
382
- unassign_task_from_tree(tree)
383
- puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
384
- true
385
- end
386
-
387
- def list_configured_apps
388
- if apps_config.any?
389
- puts "Configured apps (#{apps_config_file}):"
390
- puts
391
- apps_config.each do |name, path|
392
- puts format(" %-20s => %s", name, path)
393
- end
394
- else
395
- puts "No apps configured."
396
- puts
397
- puts "Create #{apps_config_file} with format:"
398
- puts " app_name: relative/path/from/repo"
399
- puts
400
- puts "Example:"
401
- puts " partners: partners/partners"
402
- puts " admin: admin_portal/admin"
403
- end
404
- end
405
-
406
- def app_path(app_name, task: nil)
407
- tree_root = task ? tree_path(tree_for_task(task)) : `git rev-parse --show-toplevel`.strip
408
-
409
- if app_name.nil?
410
- print tree_root
411
- exit 0
412
- end
413
-
414
- matching_apps = find_all_apps(app_name)
415
- longest_app_name = printable_apps.keys.max_by(&:length).length + 2
416
-
417
- case matching_apps.count
418
- when 0
419
- puts "ERROR: No matches found"
420
- puts
421
- puts "Possible Apps:"
422
- puts printable_apps.keys.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
423
- exit 1
424
- when 1
425
- if Pathname.pwd.ascend.any? { |parent| parent.equal?(tree_root) }
426
- pwd_to_root = Pathname.new(tree_root).relative_path_from(Pathname.pwd)
427
- print File.join(pwd_to_root, apps_config[matching_apps.first])
428
- else
429
- print File.join(tree_root, apps_config[matching_apps.first])
430
- end
431
- exit 0
432
- else
433
- puts "Multiple matches found:"
434
- puts matching_apps.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
435
- exit 1
436
- end
437
- end
438
-
439
- def printable_apps
440
- apps_config.transform_keys(&:to_s)
441
- end
442
-
443
- # Find worktrees using git worktree list
444
- def trees
445
- worktree_info.keys.sort
446
- end
447
-
448
- # Parse git worktree list output into { name => { path:, branch: } } hash
449
- def worktree_details
450
- @worktree_details ||= begin
451
- output = `git -C #{main_repo_path} worktree list --porcelain 2>/dev/null`
452
- details = {}
453
- current_path = nil
454
-
455
- work_dir = File.join(Dir.home, 'work')
456
- work_prefix = work_dir + '/'
457
-
458
- output.lines.each do |line|
459
- line = line.strip
460
- if line.start_with?('worktree ')
461
- current_path = line.sub('worktree ', '')
462
- elsif line == 'bare'
463
- # Skip bare repo
464
- current_path = nil
465
- elsif line.start_with?('branch ') || line == 'detached'
466
- branch = if line.start_with?('branch ')
467
- line.sub('branch refs/heads/', '')
468
- else
469
- '(detached)'
470
- end
471
-
472
- # Capture worktree (both named branches and detached HEAD)
473
- if current_path && current_path != main_repo_path
474
- # Use relative path from ~/work/ to support nested worktrees
475
- # e.g. ~/work/my-task/main -> "my-task/main"
476
- name = if current_path.start_with?(work_prefix)
477
- current_path.sub(work_prefix, '')
478
- else
479
- File.basename(current_path)
480
- end
481
- details[name] = { path: current_path, branch: branch }
482
- end
483
- current_path = nil
484
- end
485
- end
486
-
487
- details
488
- end
489
- end
490
-
491
- # Backward-compatible { name => path } hash
492
- def worktree_info
493
- @worktree_info ||= worktree_details.transform_values { |v| v[:path] }
494
- end
495
-
496
- # Get branch name for a worktree
497
- def worktree_branch(tree_name)
498
- worktree_details.dig(tree_name, :branch)
499
- end
500
-
501
- def clear_worktree_cache
502
- @worktree_details = nil
503
- @worktree_info = nil
504
- end
505
-
506
- # Get the main repo path (where we run git worktree commands from)
507
- def main_repo_path
508
- File.join(Dir.home, 'work', '.bare')
509
- end
510
-
511
- def tree_path(tree_name)
512
- worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
513
- end
514
-
515
- # Find an available tree (one without an active task)
516
- def find_available_tree
517
- trees.find { |tree| task_for_tree(tree).nil? }
518
- end
519
-
520
- # Get the task currently assigned to a tree
521
- def task_for_tree(tree_name)
522
- assignments[tree_name]
523
- end
524
-
525
- # Get the tree a task is assigned to
526
- def tree_for_task(task_name)
527
- assignment_for_task(task_name)&.first
528
- end
529
-
530
- def assignment_for_task(partial)
531
- assignments.find { |tree, task| task.start_with?(partial) }
532
- end
533
-
534
- def find_task(partial)
535
- assignments.values.find { |task| task.start_with?(partial) }
536
- end
537
-
538
- # Assign a task to a tree
539
- def assign_task_to_tree(task_name, tree_name)
540
- data = assignments
541
- data[tree_name] = task_name
542
- save_assignments(data)
543
- end
544
-
545
- # Unassign task from tree
546
- def unassign_task_from_tree(tree_name)
547
- data = assignments.dup
548
- data.delete(tree_name)
549
- save_assignments(data)
550
- end
551
-
552
- # Tree -> Task assignments
553
- def assignments
554
- @assignments ||= load_assignments
555
- end
556
-
557
- def load_assignments
558
- data = if File.exist?(assignments_file)
559
- YAML.safe_load_file(assignments_file) || {}
560
- else
561
- {}
562
- end
563
- data
564
- end
565
-
566
- def save_assignments(data)
567
- FileUtils.mkdir_p(task_dir)
568
- File.write(assignments_file, YAML.dump(data))
569
- @assignments = data
570
- end
571
-
572
- def assignments_file
573
- File.join(task_dir, 'assignments.yml')
574
- end
575
-
576
- # Task metadata
577
- def task_metadata(task_name)
578
- file = task_metadata_file(task_name)
579
- return nil unless File.exist?(file)
580
- YAML.safe_load_file(file)
581
- end
582
-
583
- def save_task_metadata(task_name, **data)
584
- FileUtils.mkdir_p(task_dir)
585
- existing = task_metadata(task_name) || {}
586
- merged = existing.merge(data.transform_keys(&:to_s))
587
- File.write(task_metadata_file(task_name), YAML.dump(merged))
588
- end
589
-
590
- def task_metadata_file(task_name)
591
- safe_name = task_name.gsub(/[^a-zA-Z0-9_-]/, '_')
592
- File.join(task_dir, "task_#{safe_name}.yml")
593
- end
594
-
595
- def task_dir
596
- File.join(Dir.home, '.config', 'hiiro', 'tasks')
597
- end
598
-
599
- # Session name for a task
600
- def session_name_for(task_name)
601
- task_name
602
- end
603
-
604
- # Detect current task from tmux session name
605
- def current_task
606
- return nil unless ENV['TMUX']
607
-
608
- session = `tmux display-message -p '#S'`.strip
609
-
610
- task_name = session
611
- tree = tree_for_task(task_name)
612
-
613
- return nil unless tree
614
-
615
- { task: task_name, tree: tree, session: session }
616
- end
617
-
618
- def task_name
619
- task = current_task
620
-
621
- (task || {})[:task]
622
- end
623
-
624
- def switch_to_task(task_name, tree, app: nil)
625
- session = session_name_for(task_name)
626
- base_path = tree_path(tree)
627
-
628
- if app
629
- result = find_app_path(tree, app)
630
- case result
631
- in [resolved_name, app_path]
632
- base_path = app_path
633
- puts "Using app '#{resolved_name}' as base path"
634
- else
635
- # Ignore errors on switch, just use tree root
636
- end
637
- end
638
-
639
- Dir.chdir(base_path)
640
- hiiro.start_tmux_session(session)
641
- end
642
-
643
- # Apps config from ~/.config/hiiro/apps.yml
644
- # Format: { "app_name" => "relative/path/from/repo/root" }
645
- def apps_config
646
- @apps_config ||= load_apps_config
647
- end
648
-
649
- def load_apps_config
650
- return {} unless File.exist?(apps_config_file)
651
- YAML.safe_load_file(apps_config_file) || {}
652
- end
653
-
654
- def apps_config_file
655
- File.join(Dir.home, '.config', 'hiiro', 'apps.yml')
656
- end
657
-
658
- # Find app by partial match (must be unique)
659
- def find_app(partial)
660
- matches = find_all_apps(partial)
661
-
662
- case matches.count
663
- when 0
664
- nil
665
- when 1
666
- matches.first
667
- else
668
- # Check for exact match among multiple partial matches
669
- exact = matches.find { |name| name == partial }
670
- exact ? [exact] : matches
671
- end
672
- end
673
-
674
- def find_all_apps(partial)
675
- apps_config.keys.select { |name| name.start_with?(partial) }
676
- end
677
-
678
- # App discovery within a tree
679
- def find_app_path(tree, app_name)
680
- tree_root = tree_path(tree)
681
-
682
- # First, check apps.yml config
683
- result = find_app(app_name)
684
-
685
- case result
686
- when String
687
- # Single match - use configured path
688
- return [result, File.join(tree_root, apps_config[result])]
689
- when Array
690
- # Multiple matches - return them for error reporting
691
- return [:ambiguous, result]
692
- end
693
-
694
- # Fallback: directory discovery if not in config
695
- # Look for exact match first
696
- exact = File.join(tree_root, app_name)
697
- return [app_name, exact] if Dir.exist?(exact)
698
-
699
- # Look for nested app dirs (monorepo pattern: app/app)
700
- nested = File.join(tree_root, app_name, app_name)
701
- return [app_name, nested] if Dir.exist?(nested)
702
-
703
- # Fuzzy match on directories
704
- pattern = File.join(tree_root, '*')
705
- match = Dir.glob(pattern).find { |path|
706
- File.basename(path).start_with?(app_name) && File.directory?(path)
707
- }
708
- return [File.basename(match), match] if match
709
-
710
- nil
711
- end
712
-
713
- def list_apps(tree)
714
- if apps_config.any?
715
- puts "Configured apps (from apps.yml):"
716
- apps_config.each do |name, path|
717
- puts format(" %-20s => %s", name, path)
718
- end
719
- else
720
- puts "No apps configured. Create ~/.config/hiiro/apps.yml"
721
- puts "Format:"
722
- puts " app_name: relative/path/from/repo"
723
- puts
724
- puts "Directories in tree:"
725
- tree_root = tree_path(tree)
726
- pattern = File.join(tree_root, '*')
727
- Dir.glob(pattern).select { |p| File.directory?(p) }.each do |path|
728
- puts " #{File.basename(path)}"
729
- end
730
- end
731
- end
732
-
733
- # Capture tmux window state
734
- def capture_tmux_windows(session)
735
- output = `tmux list-windows -t #{session} -F '\#{window_index}:\#{window_name}:\#{pane_current_path}'`
736
- output.lines.map(&:strip).map { |line|
737
- idx, name, path = line.split(':')
738
- { 'index' => idx, 'name' => name, 'path' => path }
739
- }
740
- end
741
-
742
- # Get all tmux sessions
743
- def tmux_sessions
744
- output = `tmux list-sessions -F '\#{session_name}' 2>/dev/null`
745
- return [] unless $?.success?
746
- output.lines.map(&:strip)
747
- end
748
-
749
- # Migrate flat worktrees (~/work/task) to nested (~/work/task/main)
750
- def migrate_worktrees
751
- # Find flat worktrees that need migration (not already nested)
752
- flat_trees = trees.reject { |name| name.include?('/') }
753
-
754
- if flat_trees.empty?
755
- puts "No flat worktrees to migrate."
756
- return
757
- end
758
-
759
- sessions = tmux_sessions
760
-
761
- puts "Worktrees to migrate:"
762
- puts
763
- flat_trees.each do |name|
764
- task = task_for_tree(name)
765
- session_marker = sessions.include?(session_name_for(task || name)) ? " (tmux session running)" : ""
766
- puts format(" %-20s => %s/main%s", name, name, session_marker)
767
- end
768
- puts
769
-
770
- flat_trees.each do |name|
771
- task = task_for_tree(name)
772
- old_path = tree_path(name)
773
- temp_name = "__migrating_#{name}"
774
- temp_path = File.join(Dir.home, 'work', temp_name)
775
- new_tree_name = "#{name}/main"
776
- new_path = File.join(Dir.home, 'work', new_tree_name)
777
-
778
- puts "Migrating '#{name}' -> '#{new_tree_name}'..."
779
-
780
- # Step 1: Move worktree to temp location (frees up the directory name)
781
- result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, temp_path)
782
- unless result
783
- puts " ERROR: Failed to move to temp location, skipping"
784
- next
785
- end
786
-
787
- # Step 2: Create parent directory and move to final nested location
788
- FileUtils.mkdir_p(File.join(Dir.home, 'work', name))
789
- result = system('git', '-C', main_repo_path, 'worktree', 'move', temp_path, new_path)
790
- unless result
791
- puts " ERROR: Failed to move to final location, rolling back..."
792
- system('git', '-C', main_repo_path, 'worktree', 'move', temp_path, old_path)
793
- next
794
- end
795
-
796
- # Step 3: Update assignments (tree name changes, task name stays)
797
- if task
798
- data = load_assignments
799
- data.delete(name)
800
- data[new_tree_name] = task
801
- save_assignments(data)
802
- end
803
-
804
- # Step 4: Update task metadata
805
- if task
806
- meta = task_metadata(task)
807
- if meta
808
- meta['tree'] = new_tree_name
809
- FileUtils.mkdir_p(task_dir)
810
- File.write(task_metadata_file(task), YAML.dump(meta))
811
- end
812
- end
813
-
814
- # Step 5: Update tmux panes if a session is running
815
- session_name = session_name_for(task || name)
816
- if sessions.include?(session_name)
817
- update_tmux_panes(session_name, old_path, new_path)
818
- end
819
-
820
- clear_worktree_cache
821
- @assignments = nil
822
- puts " Done."
823
- end
824
-
825
- puts
826
- puts "Migration complete."
827
- end
828
-
829
- # Send `cd` to tmux panes whose cwd is under the old path
830
- def update_tmux_panes(session_name, old_path, new_path)
831
- output = `tmux list-panes -s -t '#{session_name}' -F '\#{pane_id}:\#{pane_current_path}' 2>/dev/null`
832
- return unless $?.success?
833
-
834
- output.lines.each do |line|
835
- pane_id, pane_path = line.strip.split(':', 2)
836
- next unless pane_path
837
-
838
- if pane_path == old_path || pane_path.start_with?(old_path + '/')
839
- updated_path = pane_path.sub(old_path, new_path)
840
- system('tmux', 'send-keys', '-t', pane_id, "cd #{updated_path}", 'Enter')
841
- puts " Updated pane #{pane_id}: cd #{updated_path}"
842
- end
843
- end
844
- end
845
- end
846
-
847
- # Create task manager instance
848
- tasks = TaskManager.new(hiiro)
849
-
850
- # Subcommands
851
- hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
852
- hiiro.add_subcmd(:list) { tasks.list_trees }
853
- hiiro.add_subcmd(:ls) { tasks.list_trees }
854
- hiiro.add_subcmd(:start) { |task_name, app=nil| tasks.start_task(task_name, app: app) }
855
- hiiro.add_subcmd(:switch) { |task_name=nil| tasks.switch_task(task_name) }
856
- hiiro.add_subcmd(:app) { |app_name| tasks.open_app(app_name) }
857
- hiiro.add_subcmd(:path) { |app_name=nil, task=nil| tasks.app_path(app_name, task: task || tasks.task_name) }
858
- hiiro.add_subcmd(:cd) { |*args| tasks.cd_app(*args) }
859
- hiiro.add_subcmd(:apps) { tasks.list_configured_apps }
860
- hiiro.add_subcmd(:status) { tasks.status }
861
- hiiro.add_subcmd(:st) { tasks.status }
862
- hiiro.add_subcmd(:save) { tasks.save_current }
863
- hiiro.add_subcmd(:stop) { |task_name=nil| task_name ? tasks.stop_task(task_name) : tasks.stop_current }
864
- hiiro.add_subcmd(:current) { print tasks.task_name }
865
- hiiro.add_subcmd(:migrate) { tasks.migrate_worktrees }
866
-
867
- hiiro.run