hiiro 0.1.27 → 0.1.29
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.
- checksums.yaml +4 -4
- data/README.md +72 -108
- data/bin/h-subtask +11 -1
- data/bin/h-task +123 -20
- data/docs/README.md +38 -6
- data/lib/hiiro/version.rb +1 -1
- data/plugins/task.rb +200 -28
- data/script/compare +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bb0956fd77df5e3e941a772cedc2fb51376f7be1adb7ce40b217d73a300d058
|
|
4
|
+
data.tar.gz: 9436e79dae25ae352dcadfbea5e3dcaf1e3be6d435fc4ea5823b71e7425fc739
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b84f6e8bb918e7029af357a84bf4debe20a2467e9be85d77ea5e931e1b8ac64097c45b9d29b88e8db6514b7303798ef051adcb88144ff7e534c6ab0b2f4bbab
|
|
7
|
+
data.tar.gz: d901ce7695933da9452d7a08cd95cdf28b706af8e46c34eb68dbc0d4500ce7a8b4d0a479fe5b58e8201afe55131b6aa6249d682ef166632a819e11a878440b5a
|
data/README.md
CHANGED
|
@@ -69,6 +69,78 @@ h ping
|
|
|
69
69
|
# => pong
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
## Subcommands
|
|
73
|
+
|
|
74
|
+
### Base Commands
|
|
75
|
+
|
|
76
|
+
| Command | Description |
|
|
77
|
+
|---------|-------------|
|
|
78
|
+
| `h version` | Display the Hiiro version |
|
|
79
|
+
| `h ping` | Simple test command (returns "pong") |
|
|
80
|
+
| `h setup` | Install plugins and subcommands to system paths |
|
|
81
|
+
| `h edit` | Open the h script in your editor |
|
|
82
|
+
| `h path` | Print the current directory |
|
|
83
|
+
| `h ppath` | Print project path (git root + relative dir) |
|
|
84
|
+
| `h rpath` | Print relative path from git root |
|
|
85
|
+
| `h pin` | Per-command key-value storage (via Pins plugin) |
|
|
86
|
+
| `h project` | Project navigation with tmux integration (via Project plugin) |
|
|
87
|
+
| `h task` | Task management across git worktrees (via Task plugin) |
|
|
88
|
+
| `h notify` | macOS desktop notifications via terminal-notifier (via Notify plugin) |
|
|
89
|
+
|
|
90
|
+
### External Subcommands
|
|
91
|
+
|
|
92
|
+
| Command | Description |
|
|
93
|
+
|---------|-------------|
|
|
94
|
+
| `h branch` | Record and manage git branch history for tasks |
|
|
95
|
+
| `h buffer` | Tmux paste buffer management |
|
|
96
|
+
| `h dot` | Compare directories and generate symlink/diff commands |
|
|
97
|
+
| `h dotfiles` | Manage dotfiles in ~/proj/home |
|
|
98
|
+
| `h home` | Manage home directory files with edit and search |
|
|
99
|
+
| `h html` | Generate an HTML index of MP4 videos in current directory |
|
|
100
|
+
| `h link` | Manage saved links with URL, description, and shorthand |
|
|
101
|
+
| `h mic` | Control macOS microphone input volume |
|
|
102
|
+
| `h note` | Create, edit, list, and display notes |
|
|
103
|
+
| `h pane` | Tmux pane management |
|
|
104
|
+
| `h plugin` | Manage hiiro plugins (list, edit, search) |
|
|
105
|
+
| `h pr` | Record PR information linked to tasks |
|
|
106
|
+
| `h pr-monitor` | Monitor pull requests |
|
|
107
|
+
| `h pr-watch` | Watch pull requests for updates |
|
|
108
|
+
| `h project` | Open projects with tmux session management |
|
|
109
|
+
| `h runtask` | Run templated task scripts |
|
|
110
|
+
| `h serve` | Start a miniserve HTTP server on port 1111 |
|
|
111
|
+
| `h session` | Tmux session management |
|
|
112
|
+
| `h sha` | Extract short SHA from git log |
|
|
113
|
+
| `h subtask` | Shorthand for task subtask management |
|
|
114
|
+
| `h task` | Comprehensive task manager for git worktrees |
|
|
115
|
+
| `h video` | Video inspection and operations via ffprobe/ffmpeg |
|
|
116
|
+
| `h vim` | Manage nvim configuration with edit and search |
|
|
117
|
+
| `h window` | Tmux window management |
|
|
118
|
+
| `h wtree` | Git worktree management |
|
|
119
|
+
|
|
120
|
+
## Abbreviations
|
|
121
|
+
|
|
122
|
+
Any subcommand can be abbreviated as long as the prefix uniquely matches:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
h ex hel # matches h example hello
|
|
126
|
+
h te # matches h test (if unique)
|
|
127
|
+
h pp # matches h ppath
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If multiple commands match, the first match wins and a warning is logged (when logging is enabled).
|
|
131
|
+
|
|
132
|
+
## Plugins
|
|
133
|
+
|
|
134
|
+
Plugins are Ruby modules loaded from `~/.config/hiiro/plugins/`:
|
|
135
|
+
|
|
136
|
+
| Plugin | Description |
|
|
137
|
+
|--------|-------------|
|
|
138
|
+
| Pins | Per-command YAML key-value storage |
|
|
139
|
+
| Project | Project directory navigation with tmux session management |
|
|
140
|
+
| Task | Task lifecycle management across git worktrees with subtask support |
|
|
141
|
+
| Tmux | Tmux session helpers used by Project and Task |
|
|
142
|
+
| Notify | macOS desktop notifications via terminal-notifier |
|
|
143
|
+
|
|
72
144
|
## Adding Subcommands
|
|
73
145
|
|
|
74
146
|
### Method 1: External Executables
|
|
@@ -127,114 +199,6 @@ hiiro.add_subcommand(:pwd) do |*args, **values|
|
|
|
127
199
|
end
|
|
128
200
|
```
|
|
129
201
|
|
|
130
|
-
## Abbreviations
|
|
131
|
-
|
|
132
|
-
Any subcommand can be abbreviated as long as the prefix uniquely matches:
|
|
133
|
-
|
|
134
|
-
```sh
|
|
135
|
-
h ex hel # matches h example hello
|
|
136
|
-
h te # matches h test (if unique)
|
|
137
|
-
h pp # matches h ppath
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
If multiple commands match, the first match wins and a warning is logged (when logging is enabled).
|
|
141
|
-
|
|
142
|
-
## Built-in Plugins
|
|
143
|
-
|
|
144
|
-
### Pins
|
|
145
|
-
|
|
146
|
-
Key-value storage persisted per command:
|
|
147
|
-
|
|
148
|
-
```sh
|
|
149
|
-
h pin # List all pins
|
|
150
|
-
h pin mykey # Get value
|
|
151
|
-
h pin mykey myvalue # Set value
|
|
152
|
-
h pin set mykey myvalue # Set value (explicit)
|
|
153
|
-
h pin rm mykey # Remove pin
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
Pins are stored in `~/.config/hiiro/pins/<command>.yml`.
|
|
157
|
-
|
|
158
|
-
### Tmux Subcommands
|
|
159
|
-
|
|
160
|
-
Hiiro includes several tmux wrapper commands for managing sessions, windows, panes, and buffers. See [docs/](docs/) for full documentation.
|
|
161
|
-
|
|
162
|
-
```sh
|
|
163
|
-
h session ls # List tmux sessions
|
|
164
|
-
h window new # Create new window
|
|
165
|
-
h pane split # Split current pane
|
|
166
|
-
h buffer ls # List paste buffers
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### Project
|
|
170
|
-
|
|
171
|
-
Quick project navigation with tmux integration:
|
|
172
|
-
|
|
173
|
-
```sh
|
|
174
|
-
h project myproj # cd to ~/proj/myproj and start tmux session
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
Projects can be configured in `~/.config/hiiro/projects.yml`:
|
|
178
|
-
|
|
179
|
-
```yaml
|
|
180
|
-
myproject: /path/to/project
|
|
181
|
-
work: ~/work/main
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
### Task
|
|
185
|
-
|
|
186
|
-
Manage development tasks across git worktrees in `~/work/`:
|
|
187
|
-
|
|
188
|
-
```sh
|
|
189
|
-
h task list # Show trees and their active tasks
|
|
190
|
-
h task start TICKET-123 # Start working on a task
|
|
191
|
-
h task status # Show current task info
|
|
192
|
-
h task app frontend # Open app directory in new tmux window
|
|
193
|
-
h task save # Save current tmux window state
|
|
194
|
-
h task stop # Release tree for other tasks
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Configure apps in `~/.config/hiiro/apps.yml`:
|
|
198
|
-
|
|
199
|
-
```yaml
|
|
200
|
-
frontend: apps/frontend
|
|
201
|
-
api: services/api
|
|
202
|
-
admin: admin_portal/admin
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
### Notify (macOS)
|
|
206
|
-
|
|
207
|
-
Desktop notifications via `terminal-notifier`:
|
|
208
|
-
|
|
209
|
-
```sh
|
|
210
|
-
h notify "Build complete"
|
|
211
|
-
h notify "Click me" "https://example.com"
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
### Video
|
|
215
|
-
|
|
216
|
-
FFmpeg wrapper for common video operations:
|
|
217
|
-
|
|
218
|
-
```sh
|
|
219
|
-
h video info movie.mp4 # Human-readable video summary
|
|
220
|
-
h video resize720 movie.mp4 # Resize to 720p
|
|
221
|
-
h video clip movie.mp4 00:01:30 60 # Extract 60s clip starting at 1:30
|
|
222
|
-
h video gif movie.mp4 # Create animated GIF
|
|
223
|
-
h video help # Full command list
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
See [docs/h-video.md](docs/h-video.md) for complete documentation.
|
|
227
|
-
|
|
228
|
-
### Plugin
|
|
229
|
-
|
|
230
|
-
Manage hiiro plugins:
|
|
231
|
-
|
|
232
|
-
```sh
|
|
233
|
-
h plugin ls # List installed plugins
|
|
234
|
-
h plugin edit pins # Edit a plugin file
|
|
235
|
-
h plugin rg "some_method" # Search plugin code
|
|
236
|
-
```
|
|
237
|
-
|
|
238
202
|
## Writing Plugins
|
|
239
203
|
|
|
240
204
|
Plugins are Ruby modules that extend Hiiro instances:
|
data/bin/h-subtask
CHANGED
|
@@ -13,10 +13,20 @@ hiiro = Hiiro.init(*ARGV, plugins: [Tmux, Task])
|
|
|
13
13
|
tasks = hiiro.task_manager
|
|
14
14
|
|
|
15
15
|
hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
|
|
16
|
+
hiiro.add_subcmd(:list) { tasks.list_subtasks }
|
|
16
17
|
hiiro.add_subcmd(:ls) { tasks.list_subtasks }
|
|
17
18
|
hiiro.add_subcmd(:new) { |subtask_name| tasks.new_subtask(subtask_name) }
|
|
18
19
|
hiiro.add_subcmd(:switch) { |subtask_name| tasks.switch_subtask(subtask_name) }
|
|
20
|
+
hiiro.add_subcmd(:app) { |app_name| tasks.subtask_open_app(app_name) }
|
|
21
|
+
hiiro.add_subcmd(:path) { |app_name=nil| tasks.subtask_app_path(app_name) }
|
|
22
|
+
hiiro.add_subcmd(:cd) { |*args| tasks.subtask_cd_app(*args) }
|
|
23
|
+
hiiro.add_subcmd(:apps) { tasks.list_configured_apps }
|
|
24
|
+
hiiro.add_subcmd(:status) { tasks.subtask_status }
|
|
25
|
+
hiiro.add_subcmd(:st) { tasks.subtask_status }
|
|
26
|
+
hiiro.add_subcmd(:save) { tasks.save_subtask }
|
|
27
|
+
hiiro.add_subcmd(:stop) { |subtask_name=nil| subtask_name ? tasks.stop_subtask(subtask_name) : tasks.stop_subtask }
|
|
28
|
+
hiiro.add_subcmd(:current) { print tasks.current_subtask_name }
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
hiiro.add_default { tasks.subtask_help }
|
|
21
31
|
|
|
22
32
|
hiiro.run
|
data/bin/h-task
CHANGED
|
@@ -90,13 +90,18 @@ class TaskManager
|
|
|
90
90
|
# Find an available worktree to reuse, or create a new one
|
|
91
91
|
available_tree = find_available_tree
|
|
92
92
|
|
|
93
|
+
# New tasks get a nested structure: task_name/main
|
|
94
|
+
# so subtasks can live alongside as task_name/subtask_name
|
|
95
|
+
subtree_name = "#{task_name}/main"
|
|
96
|
+
|
|
93
97
|
if available_tree
|
|
94
|
-
# Rename the available worktree to the task
|
|
98
|
+
# Rename the available worktree to the task's main subtree
|
|
95
99
|
old_path = tree_path(available_tree)
|
|
96
|
-
new_path = File.join(
|
|
100
|
+
new_path = File.join(Dir.home, 'work', subtree_name)
|
|
97
101
|
|
|
98
|
-
if available_tree !=
|
|
99
|
-
puts "Renaming worktree '#{available_tree}' to '#{
|
|
102
|
+
if available_tree != subtree_name
|
|
103
|
+
puts "Renaming worktree '#{available_tree}' to '#{subtree_name}'..."
|
|
104
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
100
105
|
result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
|
|
101
106
|
unless result
|
|
102
107
|
puts "ERROR: Failed to rename worktree"
|
|
@@ -105,14 +110,15 @@ class TaskManager
|
|
|
105
110
|
clear_worktree_cache
|
|
106
111
|
end
|
|
107
112
|
|
|
108
|
-
final_tree_name =
|
|
113
|
+
final_tree_name = subtree_name
|
|
109
114
|
final_tree_path = new_path
|
|
110
115
|
else
|
|
111
116
|
# No available worktree, create a new one
|
|
112
117
|
puts "Creating new worktree for '#{task_name}'..."
|
|
113
|
-
new_path = File.join(Dir.home, 'work',
|
|
118
|
+
new_path = File.join(Dir.home, 'work', subtree_name)
|
|
114
119
|
|
|
115
120
|
# Create worktree from main branch (detached to avoid branch conflicts)
|
|
121
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
116
122
|
result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
|
|
117
123
|
unless result
|
|
118
124
|
puts "ERROR: Failed to create worktree"
|
|
@@ -120,7 +126,7 @@ class TaskManager
|
|
|
120
126
|
end
|
|
121
127
|
clear_worktree_cache
|
|
122
128
|
|
|
123
|
-
final_tree_name =
|
|
129
|
+
final_tree_name = subtree_name
|
|
124
130
|
final_tree_path = new_path
|
|
125
131
|
end
|
|
126
132
|
|
|
@@ -343,11 +349,6 @@ class TaskManager
|
|
|
343
349
|
tree = tree_for_task(task_name)
|
|
344
350
|
task_name = task_for_tree(tree)
|
|
345
351
|
|
|
346
|
-
if RESERVED_WORKTREES.key?(tree)
|
|
347
|
-
puts "Cannot stop reserved task '#{task_name}'"
|
|
348
|
-
return false
|
|
349
|
-
end
|
|
350
|
-
|
|
351
352
|
unassign_task_from_tree(tree)
|
|
352
353
|
puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
|
|
353
354
|
true
|
|
@@ -421,6 +422,9 @@ class TaskManager
|
|
|
421
422
|
info = {}
|
|
422
423
|
current_path = nil
|
|
423
424
|
|
|
425
|
+
work_dir = File.join(Dir.home, 'work')
|
|
426
|
+
work_prefix = work_dir + '/'
|
|
427
|
+
|
|
424
428
|
output.lines.each do |line|
|
|
425
429
|
line = line.strip
|
|
426
430
|
if line.start_with?('worktree ')
|
|
@@ -431,7 +435,13 @@ class TaskManager
|
|
|
431
435
|
elsif line.start_with?('branch ') || line == 'detached'
|
|
432
436
|
# Capture worktree (both named branches and detached HEAD)
|
|
433
437
|
if current_path && current_path != main_repo_path
|
|
434
|
-
|
|
438
|
+
# Use relative path from ~/work/ to support nested worktrees
|
|
439
|
+
# e.g. ~/work/my-task/main -> "my-task/main"
|
|
440
|
+
name = if current_path.start_with?(work_prefix)
|
|
441
|
+
current_path.sub(work_prefix, '')
|
|
442
|
+
else
|
|
443
|
+
File.basename(current_path)
|
|
444
|
+
end
|
|
435
445
|
info[name] = current_path
|
|
436
446
|
end
|
|
437
447
|
current_path = nil
|
|
@@ -455,12 +465,9 @@ class TaskManager
|
|
|
455
465
|
worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
|
|
456
466
|
end
|
|
457
467
|
|
|
458
|
-
# Worktrees with permanent task assignments (worktree => task)
|
|
459
|
-
RESERVED_WORKTREES = { 'carrot' => 'master' }.freeze
|
|
460
|
-
|
|
461
468
|
# Find an available tree (one without an active task)
|
|
462
469
|
def find_available_tree
|
|
463
|
-
trees.find { |tree| task_for_tree(tree).nil?
|
|
470
|
+
trees.find { |tree| task_for_tree(tree).nil? }
|
|
464
471
|
end
|
|
465
472
|
|
|
466
473
|
# Get the task currently assigned to a tree
|
|
@@ -490,7 +497,6 @@ class TaskManager
|
|
|
490
497
|
|
|
491
498
|
# Unassign task from tree
|
|
492
499
|
def unassign_task_from_tree(tree_name)
|
|
493
|
-
return if RESERVED_WORKTREES.key?(tree_name)
|
|
494
500
|
data = assignments.dup
|
|
495
501
|
data.delete(tree_name)
|
|
496
502
|
save_assignments(data)
|
|
@@ -507,8 +513,7 @@ class TaskManager
|
|
|
507
513
|
else
|
|
508
514
|
{}
|
|
509
515
|
end
|
|
510
|
-
|
|
511
|
-
RESERVED_WORKTREES.merge(data)
|
|
516
|
+
data
|
|
512
517
|
end
|
|
513
518
|
|
|
514
519
|
def save_assignments(data)
|
|
@@ -693,6 +698,103 @@ class TaskManager
|
|
|
693
698
|
return [] unless $?.success?
|
|
694
699
|
output.lines.map(&:strip)
|
|
695
700
|
end
|
|
701
|
+
|
|
702
|
+
# Migrate flat worktrees (~/work/task) to nested (~/work/task/main)
|
|
703
|
+
def migrate_worktrees
|
|
704
|
+
# Find flat worktrees that need migration (not already nested, not reserved)
|
|
705
|
+
flat_trees = trees.reject { |name| name.include?('/') || RESERVED_WORKTREES.key?(name) }
|
|
706
|
+
|
|
707
|
+
if flat_trees.empty?
|
|
708
|
+
puts "No flat worktrees to migrate."
|
|
709
|
+
return
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
sessions = tmux_sessions
|
|
713
|
+
|
|
714
|
+
puts "Worktrees to migrate:"
|
|
715
|
+
puts
|
|
716
|
+
flat_trees.each do |name|
|
|
717
|
+
task = task_for_tree(name)
|
|
718
|
+
session_marker = sessions.include?(session_name_for(task || name)) ? " (tmux session running)" : ""
|
|
719
|
+
puts format(" %-20s => %s/main%s", name, name, session_marker)
|
|
720
|
+
end
|
|
721
|
+
puts
|
|
722
|
+
|
|
723
|
+
flat_trees.each do |name|
|
|
724
|
+
task = task_for_tree(name)
|
|
725
|
+
old_path = tree_path(name)
|
|
726
|
+
temp_name = "__migrating_#{name}"
|
|
727
|
+
temp_path = File.join(Dir.home, 'work', temp_name)
|
|
728
|
+
new_tree_name = "#{name}/main"
|
|
729
|
+
new_path = File.join(Dir.home, 'work', new_tree_name)
|
|
730
|
+
|
|
731
|
+
puts "Migrating '#{name}' -> '#{new_tree_name}'..."
|
|
732
|
+
|
|
733
|
+
# Step 1: Move worktree to temp location (frees up the directory name)
|
|
734
|
+
result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, temp_path)
|
|
735
|
+
unless result
|
|
736
|
+
puts " ERROR: Failed to move to temp location, skipping"
|
|
737
|
+
next
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Step 2: Create parent directory and move to final nested location
|
|
741
|
+
FileUtils.mkdir_p(File.join(Dir.home, 'work', name))
|
|
742
|
+
result = system('git', '-C', main_repo_path, 'worktree', 'move', temp_path, new_path)
|
|
743
|
+
unless result
|
|
744
|
+
puts " ERROR: Failed to move to final location, rolling back..."
|
|
745
|
+
system('git', '-C', main_repo_path, 'worktree', 'move', temp_path, old_path)
|
|
746
|
+
next
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Step 3: Update assignments (tree name changes, task name stays)
|
|
750
|
+
if task
|
|
751
|
+
data = load_assignments
|
|
752
|
+
data.delete(name)
|
|
753
|
+
data[new_tree_name] = task
|
|
754
|
+
save_assignments(RESERVED_WORKTREES.merge(data))
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Step 4: Update task metadata
|
|
758
|
+
if task
|
|
759
|
+
meta = task_metadata(task)
|
|
760
|
+
if meta
|
|
761
|
+
meta['tree'] = new_tree_name
|
|
762
|
+
FileUtils.mkdir_p(task_dir)
|
|
763
|
+
File.write(task_metadata_file(task), YAML.dump(meta))
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Step 5: Update tmux panes if a session is running
|
|
768
|
+
session_name = session_name_for(task || name)
|
|
769
|
+
if sessions.include?(session_name)
|
|
770
|
+
update_tmux_panes(session_name, old_path, new_path)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
clear_worktree_cache
|
|
774
|
+
@assignments = nil
|
|
775
|
+
puts " Done."
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
puts
|
|
779
|
+
puts "Migration complete."
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Send `cd` to tmux panes whose cwd is under the old path
|
|
783
|
+
def update_tmux_panes(session_name, old_path, new_path)
|
|
784
|
+
output = `tmux list-panes -s -t '#{session_name}' -F '\#{pane_id}:\#{pane_current_path}' 2>/dev/null`
|
|
785
|
+
return unless $?.success?
|
|
786
|
+
|
|
787
|
+
output.lines.each do |line|
|
|
788
|
+
pane_id, pane_path = line.strip.split(':', 2)
|
|
789
|
+
next unless pane_path
|
|
790
|
+
|
|
791
|
+
if pane_path == old_path || pane_path.start_with?(old_path + '/')
|
|
792
|
+
updated_path = pane_path.sub(old_path, new_path)
|
|
793
|
+
system('tmux', 'send-keys', '-t', pane_id, "cd #{updated_path}", 'Enter')
|
|
794
|
+
puts " Updated pane #{pane_id}: cd #{updated_path}"
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
end
|
|
696
798
|
end
|
|
697
799
|
|
|
698
800
|
# Create task manager instance
|
|
@@ -713,5 +815,6 @@ hiiro.add_subcmd(:st) { tasks.status }
|
|
|
713
815
|
hiiro.add_subcmd(:save) { tasks.save_current }
|
|
714
816
|
hiiro.add_subcmd(:stop) { |task_name=nil| task_name ? tasks.stop_task(task_name) : tasks.stop_current }
|
|
715
817
|
hiiro.add_subcmd(:current) { print tasks.task_name }
|
|
818
|
+
hiiro.add_subcmd(:migrate) { tasks.migrate_worktrees }
|
|
716
819
|
|
|
717
820
|
hiiro.run
|
data/docs/README.md
CHANGED
|
@@ -9,11 +9,30 @@ This directory contains detailed documentation for all Hiiro subcommands.
|
|
|
9
9
|
| Command | Description |
|
|
10
10
|
|---------|-------------|
|
|
11
11
|
| [h-buffer](h-buffer.md) | Tmux paste buffer management |
|
|
12
|
+
| h-branch | Record and manage git branch history for tasks |
|
|
13
|
+
| h-dot | Compare directories and generate symlink/diff commands |
|
|
14
|
+
| h-dotfiles | Manage dotfiles in ~/proj/home |
|
|
15
|
+
| h-home | Manage home directory files with edit and search |
|
|
16
|
+
| h-html | Generate an HTML index of MP4 videos in current directory |
|
|
17
|
+
| h-link | Manage saved links with URL, description, and shorthand |
|
|
18
|
+
| h-mic | Control macOS microphone input volume |
|
|
19
|
+
| h-note | Create, edit, list, and display notes |
|
|
12
20
|
| [h-pane](h-pane.md) | Tmux pane management |
|
|
13
|
-
| [h-plugin](h-plugin.md) |
|
|
21
|
+
| [h-plugin](h-plugin.md) | Manage hiiro plugins (list, edit, search) |
|
|
22
|
+
| h-pr | Record PR information linked to tasks |
|
|
23
|
+
| h-pr-monitor | Monitor pull requests |
|
|
24
|
+
| h-pr-watch | Watch pull requests for updates |
|
|
25
|
+
| h-project | Open projects with tmux session management |
|
|
26
|
+
| h-runtask | Run templated task scripts |
|
|
27
|
+
| h-serve | Start a miniserve HTTP server on port 1111 |
|
|
14
28
|
| [h-session](h-session.md) | Tmux session management |
|
|
15
|
-
|
|
|
29
|
+
| h-sha | Extract short SHA from git log |
|
|
30
|
+
| h-subtask | Shorthand for task subtask management |
|
|
31
|
+
| h-task | Comprehensive task manager for git worktrees |
|
|
32
|
+
| [h-video](h-video.md) | Video inspection and operations via ffprobe/ffmpeg |
|
|
33
|
+
| h-vim | Manage nvim configuration with edit and search |
|
|
16
34
|
| [h-window](h-window.md) | Tmux window management |
|
|
35
|
+
| h-wtree | Git worktree management |
|
|
17
36
|
|
|
18
37
|
## Base Commands
|
|
19
38
|
|
|
@@ -21,14 +40,27 @@ The main `h` command includes these built-in subcommands:
|
|
|
21
40
|
|
|
22
41
|
| Command | Description |
|
|
23
42
|
|---------|-------------|
|
|
43
|
+
| `h version` | Display the Hiiro version |
|
|
44
|
+
| `h ping` | Simple test command (returns "pong") |
|
|
45
|
+
| `h setup` | Install plugins and subcommands to system paths |
|
|
24
46
|
| `h edit` | Open the h script in your editor |
|
|
25
47
|
| `h path` | Print the current directory |
|
|
26
48
|
| `h ppath` | Print project path (git root + relative dir) |
|
|
27
49
|
| `h rpath` | Print relative path from git root |
|
|
28
|
-
| `h
|
|
29
|
-
| `h
|
|
30
|
-
| `h
|
|
31
|
-
| `h
|
|
50
|
+
| `h pin` | Per-command key-value storage (via Pins plugin) |
|
|
51
|
+
| `h project` | Project navigation with tmux integration (via Project plugin) |
|
|
52
|
+
| `h task` | Task management across git worktrees (via Task plugin) |
|
|
53
|
+
| `h notify` | macOS desktop notifications via terminal-notifier (via Notify plugin) |
|
|
54
|
+
|
|
55
|
+
## Plugins
|
|
56
|
+
|
|
57
|
+
| Plugin | Description |
|
|
58
|
+
|--------|-------------|
|
|
59
|
+
| Pins | Per-command YAML key-value storage |
|
|
60
|
+
| Project | Project directory navigation with tmux session management |
|
|
61
|
+
| Task | Task lifecycle management across git worktrees with subtask support |
|
|
62
|
+
| Tmux | Tmux session helpers used by Project and Task |
|
|
63
|
+
| Notify | macOS desktop notifications via terminal-notifier |
|
|
32
64
|
|
|
33
65
|
## Abbreviations
|
|
34
66
|
|
data/lib/hiiro/version.rb
CHANGED
data/plugins/task.rb
CHANGED
|
@@ -143,28 +143,29 @@ module Task
|
|
|
143
143
|
return true
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
# If a specific tree was requested, verify it exists
|
|
146
|
+
# If a specific tree was requested, verify it exists
|
|
147
147
|
if tree
|
|
148
148
|
if !trees.include?(tree)
|
|
149
149
|
puts "ERROR: Worktree '#{tree}' not found"
|
|
150
150
|
return false
|
|
151
151
|
end
|
|
152
|
-
if RESERVED_WORKTREES.key?(tree)
|
|
153
|
-
puts "ERROR: Worktree '#{tree}' is reserved and cannot be used for tasks"
|
|
154
|
-
return false
|
|
155
|
-
end
|
|
156
152
|
end
|
|
157
153
|
|
|
158
154
|
# Find an available worktree to reuse, or create a new one
|
|
159
155
|
available_tree = tree || find_available_tree
|
|
160
156
|
|
|
157
|
+
# New tasks get a nested structure: task_name/main
|
|
158
|
+
# so subtasks can live alongside as task_name/subtask_name
|
|
159
|
+
subtree_name = "#{task_name}/main"
|
|
160
|
+
|
|
161
161
|
if available_tree
|
|
162
|
-
# Rename the available worktree to the task
|
|
162
|
+
# Rename the available worktree to the task's main subtree
|
|
163
163
|
old_path = tree_path(available_tree)
|
|
164
|
-
new_path = File.join(
|
|
164
|
+
new_path = File.join(Dir.home, 'work', subtree_name)
|
|
165
165
|
|
|
166
|
-
if available_tree !=
|
|
167
|
-
puts "Renaming worktree '#{available_tree}' to '#{
|
|
166
|
+
if available_tree != subtree_name
|
|
167
|
+
puts "Renaming worktree '#{available_tree}' to '#{subtree_name}'..."
|
|
168
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
168
169
|
result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
|
|
169
170
|
unless result
|
|
170
171
|
puts "ERROR: Failed to rename worktree"
|
|
@@ -173,14 +174,15 @@ module Task
|
|
|
173
174
|
clear_worktree_cache
|
|
174
175
|
end
|
|
175
176
|
|
|
176
|
-
final_tree_name =
|
|
177
|
+
final_tree_name = subtree_name
|
|
177
178
|
final_tree_path = new_path
|
|
178
179
|
else
|
|
179
180
|
# No available worktree, create a new one
|
|
180
181
|
puts "Creating new worktree for '#{task_name}'..."
|
|
181
|
-
new_path = File.join(Dir.home, 'work',
|
|
182
|
+
new_path = File.join(Dir.home, 'work', subtree_name)
|
|
182
183
|
|
|
183
184
|
# Create worktree from main branch (detached to avoid branch conflicts)
|
|
185
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
184
186
|
result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
|
|
185
187
|
unless result
|
|
186
188
|
puts "ERROR: Failed to create worktree"
|
|
@@ -188,7 +190,7 @@ module Task
|
|
|
188
190
|
end
|
|
189
191
|
clear_worktree_cache
|
|
190
192
|
|
|
191
|
-
final_tree_name =
|
|
193
|
+
final_tree_name = subtree_name
|
|
192
194
|
final_tree_path = new_path
|
|
193
195
|
end
|
|
194
196
|
|
|
@@ -361,11 +363,6 @@ module Task
|
|
|
361
363
|
tree = tree_for_task(task_name)
|
|
362
364
|
task_name = task_for_tree(tree)
|
|
363
365
|
|
|
364
|
-
if RESERVED_WORKTREES.key?(tree)
|
|
365
|
-
puts "Cannot stop reserved task '#{task_name}'"
|
|
366
|
-
return false
|
|
367
|
-
end
|
|
368
|
-
|
|
369
366
|
unassign_task_from_tree(tree)
|
|
370
367
|
puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
|
|
371
368
|
true
|
|
@@ -377,21 +374,49 @@ module Task
|
|
|
377
374
|
puts " h subtask <subcommand> [args]"
|
|
378
375
|
puts
|
|
379
376
|
puts "Subcommands:"
|
|
380
|
-
puts " ls
|
|
377
|
+
puts " list, ls List subtasks for current task"
|
|
381
378
|
puts " new SUBTASK_NAME Start a new subtask (creates worktree and session)"
|
|
382
379
|
puts " switch SUBTASK_NAME Switch to subtask's tmux session"
|
|
380
|
+
puts " app APP_NAME Open a tmux window for an app in current subtask"
|
|
381
|
+
puts " apps List configured apps from apps.yml"
|
|
382
|
+
puts " cd [APP_NAME] Change directory to app in current subtask"
|
|
383
|
+
puts " path [APP_NAME] Print app path in current subtask"
|
|
384
|
+
puts " status, st Show current subtask status"
|
|
385
|
+
puts " save Save current subtask session info"
|
|
386
|
+
puts " stop [SUBTASK_NAME] Stop working on current/named subtask"
|
|
387
|
+
puts " current Print current subtask name"
|
|
383
388
|
end
|
|
384
389
|
|
|
385
390
|
def handle_subtask(*args)
|
|
386
391
|
case args
|
|
387
392
|
in []
|
|
388
393
|
subtask_help
|
|
389
|
-
in ['ls']
|
|
394
|
+
in ['list'] | ['ls']
|
|
390
395
|
list_subtasks
|
|
391
396
|
in ['new', subtask_name]
|
|
392
397
|
new_subtask(subtask_name)
|
|
393
398
|
in ['switch', subtask_name]
|
|
394
399
|
switch_subtask(subtask_name)
|
|
400
|
+
in ['app', app_name]
|
|
401
|
+
subtask_open_app(app_name)
|
|
402
|
+
in ['apps']
|
|
403
|
+
list_configured_apps
|
|
404
|
+
in ['cd', *cd_args]
|
|
405
|
+
subtask_cd_app(*cd_args)
|
|
406
|
+
in ['path']
|
|
407
|
+
subtask_app_path
|
|
408
|
+
in ['path', app_name]
|
|
409
|
+
subtask_app_path(app_name)
|
|
410
|
+
in ['status'] | ['st']
|
|
411
|
+
subtask_status
|
|
412
|
+
in ['save']
|
|
413
|
+
save_subtask
|
|
414
|
+
in ['stop']
|
|
415
|
+
stop_subtask
|
|
416
|
+
in ['stop', subtask_name]
|
|
417
|
+
stop_subtask(subtask_name)
|
|
418
|
+
in ['current']
|
|
419
|
+
print current_subtask_name
|
|
395
420
|
else
|
|
396
421
|
puts "Unknown subtask command: #{args.inspect}"
|
|
397
422
|
subtask_help
|
|
@@ -406,6 +431,10 @@ module Task
|
|
|
406
431
|
end
|
|
407
432
|
|
|
408
433
|
parent_task = current[:task]
|
|
434
|
+
# Handle if we're in a subtask - get the parent
|
|
435
|
+
if parent_task.include?('/')
|
|
436
|
+
parent_task = parent_task.split('/').first
|
|
437
|
+
end
|
|
409
438
|
subtasks = subtasks_for_task(parent_task)
|
|
410
439
|
|
|
411
440
|
if subtasks.empty?
|
|
@@ -437,7 +466,18 @@ module Task
|
|
|
437
466
|
end
|
|
438
467
|
|
|
439
468
|
parent_task = current[:task]
|
|
440
|
-
|
|
469
|
+
|
|
470
|
+
if parent_task.include?('/')
|
|
471
|
+
parent_task = parent_task.split('/').first
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
parent_dir =
|
|
475
|
+
if worktree_info.key?(parent_task)
|
|
476
|
+
"#{parent_task}_subtasks"
|
|
477
|
+
else
|
|
478
|
+
parent_task
|
|
479
|
+
end
|
|
480
|
+
full_subtask_name = "#{parent_dir}/#{subtask_name}"
|
|
441
481
|
|
|
442
482
|
# Check if subtask already exists
|
|
443
483
|
existing = subtasks_for_task(parent_task).find { |s| s['name'] == subtask_name }
|
|
@@ -525,6 +565,122 @@ module Task
|
|
|
525
565
|
true
|
|
526
566
|
end
|
|
527
567
|
|
|
568
|
+
# Show status for the current subtask
|
|
569
|
+
def subtask_status
|
|
570
|
+
current = current_task
|
|
571
|
+
unless current
|
|
572
|
+
puts "Not currently in a task session"
|
|
573
|
+
return
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
parent_task = parent_task_name(current[:task])
|
|
577
|
+
subtask_name = current_subtask_name_from(current[:task])
|
|
578
|
+
|
|
579
|
+
puts "Parent task: #{parent_task}"
|
|
580
|
+
if subtask_name
|
|
581
|
+
puts "Subtask: #{subtask_name}"
|
|
582
|
+
else
|
|
583
|
+
puts "Subtask: (main)"
|
|
584
|
+
end
|
|
585
|
+
puts "Worktree: #{current[:tree]}"
|
|
586
|
+
puts "Path: #{tree_path(current[:tree])}"
|
|
587
|
+
puts "Session: #{current[:session]}"
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Save current subtask tmux session state
|
|
591
|
+
def save_subtask
|
|
592
|
+
current = current_task
|
|
593
|
+
unless current
|
|
594
|
+
puts "ERROR: Not currently in a task session"
|
|
595
|
+
return false
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
parent_task = parent_task_name(current[:task])
|
|
599
|
+
session = current[:session]
|
|
600
|
+
|
|
601
|
+
windows = capture_tmux_windows(session)
|
|
602
|
+
|
|
603
|
+
save_task_metadata(parent_task,
|
|
604
|
+
tree: current[:tree],
|
|
605
|
+
session: session,
|
|
606
|
+
windows: windows,
|
|
607
|
+
saved_at: Time.now.iso8601
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
puts "Saved subtask session state (#{windows.count} windows)"
|
|
611
|
+
true
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Stop a subtask (remove from parent's subtask list, unassign tree)
|
|
615
|
+
def stop_subtask(subtask_name = nil)
|
|
616
|
+
current = current_task
|
|
617
|
+
unless current
|
|
618
|
+
puts "Not currently in a task session"
|
|
619
|
+
return false
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
parent_task = parent_task_name(current[:task])
|
|
623
|
+
|
|
624
|
+
if subtask_name.nil?
|
|
625
|
+
# Stop the current subtask
|
|
626
|
+
subtask_name = current_subtask_name_from(current[:task])
|
|
627
|
+
unless subtask_name
|
|
628
|
+
puts "Not currently in a subtask (in main task)"
|
|
629
|
+
return false
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
subtask = find_subtask(parent_task, subtask_name)
|
|
634
|
+
unless subtask
|
|
635
|
+
puts "Subtask '#{subtask_name}' not found for task '#{parent_task}'"
|
|
636
|
+
return false
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
tree_name = subtask['worktree']
|
|
640
|
+
if tree_name
|
|
641
|
+
unassign_task_from_tree(tree_name)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Mark subtask as inactive in parent metadata
|
|
645
|
+
meta = task_metadata(parent_task) || {}
|
|
646
|
+
if meta['subtasks']
|
|
647
|
+
meta['subtasks'].each do |s|
|
|
648
|
+
if s['name'].start_with?(subtask_name)
|
|
649
|
+
s['active'] = false
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
FileUtils.mkdir_p(task_dir)
|
|
653
|
+
File.write(task_metadata_file(parent_task), YAML.dump(meta))
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
puts "Stopped subtask '#{subtask_name}' (worktree available for reuse)"
|
|
657
|
+
true
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# Print the current subtask name
|
|
661
|
+
def current_subtask_name
|
|
662
|
+
current = current_task
|
|
663
|
+
return nil unless current
|
|
664
|
+
|
|
665
|
+
subtask_name = current_subtask_name_from(current[:task])
|
|
666
|
+
subtask_name || current[:task]
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Open an app window scoped to the current subtask's tree
|
|
670
|
+
def subtask_open_app(app_name)
|
|
671
|
+
open_app(app_name)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# cd to app in current subtask's tree
|
|
675
|
+
def subtask_cd_app(*args)
|
|
676
|
+
cd_app(*args)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Print app path in current subtask's tree
|
|
680
|
+
def subtask_app_path(app_name = nil, task: nil)
|
|
681
|
+
app_path(app_name, task: task || current_task&.dig(:task))
|
|
682
|
+
end
|
|
683
|
+
|
|
528
684
|
private
|
|
529
685
|
|
|
530
686
|
def subtasks_for_task(task_name)
|
|
@@ -546,6 +702,18 @@ module Task
|
|
|
546
702
|
File.write(task_metadata_file(parent_task), YAML.dump(meta))
|
|
547
703
|
end
|
|
548
704
|
|
|
705
|
+
# Extract parent task name from a potentially nested task/subtask name
|
|
706
|
+
def parent_task_name(task_name)
|
|
707
|
+
task_name.include?('/') ? task_name.split('/').first : task_name
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Extract subtask name from a nested task name (returns nil if main)
|
|
711
|
+
def current_subtask_name_from(task_name)
|
|
712
|
+
return nil unless task_name.include?('/')
|
|
713
|
+
parts = task_name.split('/', 2)
|
|
714
|
+
parts.last == 'main' ? nil : parts.last
|
|
715
|
+
end
|
|
716
|
+
|
|
549
717
|
public
|
|
550
718
|
|
|
551
719
|
def list_configured_apps
|
|
@@ -613,6 +781,9 @@ module Task
|
|
|
613
781
|
info = {}
|
|
614
782
|
current_path = nil
|
|
615
783
|
|
|
784
|
+
work_dir = File.join(Dir.home, 'work')
|
|
785
|
+
work_prefix = work_dir + '/'
|
|
786
|
+
|
|
616
787
|
output.lines.each do |line|
|
|
617
788
|
line = line.strip
|
|
618
789
|
if line.start_with?('worktree ')
|
|
@@ -623,7 +794,13 @@ module Task
|
|
|
623
794
|
elsif line.start_with?('branch ') || line == 'detached'
|
|
624
795
|
# Capture worktree (both named branches and detached HEAD)
|
|
625
796
|
if current_path && current_path != main_repo_path
|
|
626
|
-
|
|
797
|
+
# Use relative path from ~/work/ to support nested worktrees
|
|
798
|
+
# e.g. ~/work/my-task/main -> "my-task/main"
|
|
799
|
+
name = if current_path.start_with?(work_prefix)
|
|
800
|
+
current_path.sub(work_prefix, '')
|
|
801
|
+
else
|
|
802
|
+
File.basename(current_path)
|
|
803
|
+
end
|
|
627
804
|
info[name] = current_path
|
|
628
805
|
end
|
|
629
806
|
current_path = nil
|
|
@@ -647,12 +824,9 @@ module Task
|
|
|
647
824
|
worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
|
|
648
825
|
end
|
|
649
826
|
|
|
650
|
-
# Worktrees with permanent task assignments (worktree => task)
|
|
651
|
-
RESERVED_WORKTREES = { 'carrot' => 'master' }.freeze
|
|
652
|
-
|
|
653
827
|
# Find an available tree (one without an active task)
|
|
654
828
|
def find_available_tree
|
|
655
|
-
trees.find { |tree| task_for_tree(tree).nil?
|
|
829
|
+
trees.find { |tree| task_for_tree(tree).nil? }
|
|
656
830
|
end
|
|
657
831
|
|
|
658
832
|
# Get the task currently assigned to a tree
|
|
@@ -682,7 +856,6 @@ module Task
|
|
|
682
856
|
|
|
683
857
|
# Unassign task from tree
|
|
684
858
|
def unassign_task_from_tree(tree_name)
|
|
685
|
-
return if RESERVED_WORKTREES.key?(tree_name)
|
|
686
859
|
data = assignments.dup
|
|
687
860
|
data.delete(tree_name)
|
|
688
861
|
save_assignments(data)
|
|
@@ -699,8 +872,7 @@ module Task
|
|
|
699
872
|
else
|
|
700
873
|
{}
|
|
701
874
|
end
|
|
702
|
-
|
|
703
|
-
RESERVED_WORKTREES.merge(data)
|
|
875
|
+
data
|
|
704
876
|
end
|
|
705
877
|
|
|
706
878
|
def save_assignments(data)
|
data/script/compare
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
3
|
diff_dirs() {
|
|
4
|
-
diff -qrs
|
|
4
|
+
diff -qrs bin/ ~/bin/ | egrep '\bbin(: |\/)h(\b|-)' | egrep -v 'identical'
|
|
5
5
|
|
|
6
|
-
diff -qrs ~/.config/hiiro/plugins/
|
|
6
|
+
diff -qrs plugins/ ~/.config/hiiro/plugins/ | egrep -v 'identical'
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
# s@Only in \(bin|plugins\): \(.*\)@cp -v \1/\2 $HOME/\1/@;
|