hiiro 0.1.48 → 0.1.49
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/bin/h-branch +4 -4
- data/bin/h-pr +4 -4
- data/lib/hiiro/version.rb +1 -1
- metadata +1 -2
- data/plugins/old_task.rb +0 -1156
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca5fd8cc7a86be8ac4dca336cc5283ce37eb788eb9d31bb1721ee6cdf339b40d
|
|
4
|
+
data.tar.gz: 8d781bbe42948a4a3143c23e2e560dbe6e756a8b6a40080810e458a12a2c2e43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 36f287505cae5cf1dab0d39657c4ee2a3718e058298975843ef6e173a09070827c22320216d22b744a3858885dab49d527242f53767f4e498f1195a91b7c8f5b
|
|
7
|
+
data.tar.gz: 721e1a27f2de6207743e70aefbafa32d22724f4eb3d13af99a6bd9937ebf0306ea300cf15d4a2dffd253c528ec00d2a7dd0bf7b950c5b61c7de48b83662f3b89
|
data/bin/h-branch
CHANGED
|
@@ -5,7 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "yaml"
|
|
6
6
|
|
|
7
7
|
Hiiro.load_env
|
|
8
|
-
hiiro = Hiiro.init(*ARGV, plugins: [
|
|
8
|
+
hiiro = Hiiro.init(*ARGV, plugins: [Tasks])
|
|
9
9
|
|
|
10
10
|
class BranchManager
|
|
11
11
|
attr_reader :hiiro
|
|
@@ -112,13 +112,13 @@ class BranchManager
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def build_entry(branch_name)
|
|
115
|
-
|
|
115
|
+
current_task = Environment.current.task
|
|
116
116
|
tmux_info = capture_tmux_info
|
|
117
117
|
|
|
118
118
|
{
|
|
119
119
|
name: branch_name,
|
|
120
|
-
worktree:
|
|
121
|
-
task:
|
|
120
|
+
worktree: current_task&.tree_name,
|
|
121
|
+
task: current_task&.name,
|
|
122
122
|
tmux: tmux_info
|
|
123
123
|
}
|
|
124
124
|
end
|
data/bin/h-pr
CHANGED
|
@@ -7,7 +7,7 @@ require "yaml"
|
|
|
7
7
|
require "json"
|
|
8
8
|
|
|
9
9
|
Hiiro.load_env
|
|
10
|
-
hiiro = Hiiro.init(*ARGV, plugins: [
|
|
10
|
+
hiiro = Hiiro.init(*ARGV, plugins: [Tasks, Tmux, Pins])
|
|
11
11
|
|
|
12
12
|
class PRManager
|
|
13
13
|
attr_reader :hiiro
|
|
@@ -144,7 +144,7 @@ class PRManager
|
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
def build_entry(pr_info)
|
|
147
|
-
|
|
147
|
+
current_task = Environment.current.task
|
|
148
148
|
tmux_info = capture_tmux_info
|
|
149
149
|
|
|
150
150
|
{
|
|
@@ -153,8 +153,8 @@ class PRManager
|
|
|
153
153
|
url: pr_info['url'],
|
|
154
154
|
branch: pr_info['headRefName'],
|
|
155
155
|
state: pr_info['state'],
|
|
156
|
-
worktree:
|
|
157
|
-
task:
|
|
156
|
+
worktree: current_task&.tree_name,
|
|
157
|
+
task: current_task&.name,
|
|
158
158
|
tmux: tmux_info
|
|
159
159
|
}
|
|
160
160
|
end
|
data/lib/hiiro/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hiiro
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.49
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joshua Toyota
|
|
@@ -75,7 +75,6 @@ files:
|
|
|
75
75
|
- lib/hiiro/version.rb
|
|
76
76
|
- notes
|
|
77
77
|
- plugins/notify.rb
|
|
78
|
-
- plugins/old_task.rb
|
|
79
78
|
- plugins/pins.rb
|
|
80
79
|
- plugins/project.rb
|
|
81
80
|
- plugins/tasks.rb
|
data/plugins/old_task.rb
DELETED
|
@@ -1,1156 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
module OldTask
|
|
4
|
-
def self.load(hiiro)
|
|
5
|
-
hiiro.load_plugin(Tmux)
|
|
6
|
-
attach_methods(hiiro)
|
|
7
|
-
add_subcommands(hiiro)
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def self.add_subcommands(hiiro)
|
|
11
|
-
hiiro.add_subcmd(:task) do |*args|
|
|
12
|
-
tasks = hiiro.task_manager
|
|
13
|
-
|
|
14
|
-
runner_map = {
|
|
15
|
-
edit: ->(*sargs) { system(ENV['EDITOR'] || 'nvim', __FILE__) },
|
|
16
|
-
list: ->(*sargs) { tasks.list_trees },
|
|
17
|
-
ls: ->(*sargs) { tasks.list_trees },
|
|
18
|
-
start: ->(task_name, tree=nil) { tasks.start_task(task_name, tree:) },
|
|
19
|
-
switch: ->(task_name) { tasks.switch_task(task_name) },
|
|
20
|
-
app: ->(*sargs) { tasks.open_app(*sargs) },
|
|
21
|
-
path: ->(app_name=nil, task=nil) { tasks.app_path(app_name, task: task) },
|
|
22
|
-
cd: ->(*sargs) { tasks.cd_app(*sargs) },
|
|
23
|
-
apps: ->(*sargs) { tasks.list_configured_apps },
|
|
24
|
-
status: ->(*sargs) { tasks.status },
|
|
25
|
-
save: ->(*sargs) { tasks.save_current },
|
|
26
|
-
stop: ->(*sargs) { tasks.stop_current },
|
|
27
|
-
subtask: ->(*sargs) { tasks.handle_subtask(*sargs) },
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
case args
|
|
31
|
-
in []
|
|
32
|
-
tasks.help
|
|
33
|
-
in ['edit']
|
|
34
|
-
runner_map[:edit].call
|
|
35
|
-
in ['list'] | ['ls']
|
|
36
|
-
tasks.list_trees
|
|
37
|
-
in ['start', task_name]
|
|
38
|
-
tasks.start_task(task_name)
|
|
39
|
-
in ['start', task_name, tree]
|
|
40
|
-
tasks.start_task(task_name, tree: tree)
|
|
41
|
-
in ['app', app_name]
|
|
42
|
-
tasks.open_app(app_name)
|
|
43
|
-
in ['apps']
|
|
44
|
-
tasks.list_configured_apps
|
|
45
|
-
in ['save']
|
|
46
|
-
tasks.save_current
|
|
47
|
-
in ['status'] | ['st']
|
|
48
|
-
tasks.status
|
|
49
|
-
in ['stop']
|
|
50
|
-
tasks.stop_current
|
|
51
|
-
in ['stop', task_name]
|
|
52
|
-
tasks.stop_task(task_name)
|
|
53
|
-
in ['subtask']
|
|
54
|
-
tasks.subtask_help
|
|
55
|
-
in ['subtask', 'ls']
|
|
56
|
-
tasks.list_subtasks
|
|
57
|
-
in ['subtask', 'new', subtask_name]
|
|
58
|
-
tasks.new_subtask(subtask_name)
|
|
59
|
-
in ['subtask', 'switch', subtask_name]
|
|
60
|
-
tasks.switch_subtask(subtask_name)
|
|
61
|
-
in ['subtask', 'switch']
|
|
62
|
-
tasks.switch_subtask
|
|
63
|
-
in [subcmd, *sargs]
|
|
64
|
-
match = runner_map.keys.find { |full_subcmd| full_subcmd.to_s.start_with?(subcmd) }
|
|
65
|
-
|
|
66
|
-
if match
|
|
67
|
-
runner_map[match].call(*sargs)
|
|
68
|
-
else
|
|
69
|
-
puts "Unknown task subcommand: #{args.inspect}"
|
|
70
|
-
tasks.help
|
|
71
|
-
end
|
|
72
|
-
else
|
|
73
|
-
puts "Unknown task subcommand: #{args.inspect}"
|
|
74
|
-
tasks.help
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def self.attach_methods(hiiro)
|
|
80
|
-
hiiro.instance_eval do
|
|
81
|
-
def task_manager
|
|
82
|
-
@task_manager ||= OldTask::TaskManager.new(self)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
class TaskManager
|
|
88
|
-
attr_reader :hiiro
|
|
89
|
-
|
|
90
|
-
def initialize(hiiro)
|
|
91
|
-
@hiiro = hiiro
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def help
|
|
95
|
-
puts "Usage: h task <subcommand> [args]"
|
|
96
|
-
puts
|
|
97
|
-
puts "Subcommands:"
|
|
98
|
-
puts " list, ls List all worktrees and their active tasks"
|
|
99
|
-
puts " start TASK Start a task (reuses available worktree or creates new)"
|
|
100
|
-
puts " switch TASK Switch to an existing task"
|
|
101
|
-
puts " app APP_NAME Open a tmux window for an app in current worktree"
|
|
102
|
-
puts " apps List configured apps from apps.yml"
|
|
103
|
-
puts " save Save current tmux session info for this task"
|
|
104
|
-
puts " status, st Show current task status"
|
|
105
|
-
puts " stop Stop working on current task (worktree becomes available)"
|
|
106
|
-
puts " subtask <subcmd> Manage subtasks (ls, new, switch)"
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# List all worktrees and their active tasks
|
|
110
|
-
def list_trees
|
|
111
|
-
puts "Git worktrees:"
|
|
112
|
-
puts
|
|
113
|
-
|
|
114
|
-
if trees.empty?
|
|
115
|
-
puts " (no worktrees found)"
|
|
116
|
-
puts
|
|
117
|
-
puts " Start a task with 'h task start TASK_NAME' to create one."
|
|
118
|
-
return
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
current = current_task
|
|
122
|
-
active, available = trees.partition { |tree_name| task_for_tree(tree_name) }
|
|
123
|
-
|
|
124
|
-
# Group active trees by parent task
|
|
125
|
-
groups = {}
|
|
126
|
-
active.each do |tree_name|
|
|
127
|
-
task = task_for_tree(tree_name)
|
|
128
|
-
parent = task.include?('/') ? task.split('/').first : task
|
|
129
|
-
groups[parent] ||= []
|
|
130
|
-
groups[parent] << { tree: tree_name, task: task }
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
first_group = true
|
|
134
|
-
groups.each do |parent, entries|
|
|
135
|
-
puts unless first_group
|
|
136
|
-
first_group = false
|
|
137
|
-
|
|
138
|
-
# Sort so the main entry (parent itself or parent/main) comes first
|
|
139
|
-
entries.sort_by! { |e| e[:task] == parent || e[:task].end_with?('/main') ? 0 : 1 }
|
|
140
|
-
|
|
141
|
-
entries.each_with_index do |entry, i|
|
|
142
|
-
tree_name = entry[:tree]
|
|
143
|
-
task = entry[:task]
|
|
144
|
-
marker = (current && current[:tree] == tree_name) ? "*" : " "
|
|
145
|
-
branch = worktree_branch(tree_name)
|
|
146
|
-
branch_str = branch ? " [#{branch}]" : ""
|
|
147
|
-
|
|
148
|
-
if i == 0
|
|
149
|
-
# Parent task line
|
|
150
|
-
display_name = parent
|
|
151
|
-
puts format("%s %s%s", marker, display_name, branch_str)
|
|
152
|
-
else
|
|
153
|
-
# Subtask line: align /child_name under the parent name
|
|
154
|
-
child_name = task.include?('/') ? task.split('/', 2).last : task
|
|
155
|
-
padding = " " * parent.length
|
|
156
|
-
puts format("%s %s/%s%s", marker, padding, child_name, branch_str)
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
if available.any?
|
|
162
|
-
puts
|
|
163
|
-
available.each do |tree_name|
|
|
164
|
-
branch = worktree_branch(tree_name)
|
|
165
|
-
branch_str = branch ? " [#{branch}]" : ""
|
|
166
|
-
puts format(" %-20s (available)%s", tree_name, branch_str)
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Start working on a task
|
|
172
|
-
def start_task(task_name, tree: nil)
|
|
173
|
-
# Check if task already exists as a worktree
|
|
174
|
-
existing_tree = tree_for_task(task_name)
|
|
175
|
-
if existing_tree
|
|
176
|
-
puts "Task '#{task_name}' already active in tree '#{existing_tree}'"
|
|
177
|
-
puts "Switching to existing session..."
|
|
178
|
-
switch_to_task(task_name, existing_tree)
|
|
179
|
-
return true
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# If a specific tree was requested, verify it exists
|
|
183
|
-
if tree
|
|
184
|
-
if !trees.include?(tree)
|
|
185
|
-
puts "ERROR: Worktree '#{tree}' not found"
|
|
186
|
-
return false
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Find an available worktree to reuse, or create a new one
|
|
191
|
-
available_tree = tree || find_available_tree
|
|
192
|
-
|
|
193
|
-
# New tasks get a nested structure: task_name/main
|
|
194
|
-
# so subtasks can live alongside as task_name/subtask_name
|
|
195
|
-
subtree_name = "#{task_name}/main"
|
|
196
|
-
|
|
197
|
-
if available_tree
|
|
198
|
-
# Rename the available worktree to the task's main subtree
|
|
199
|
-
old_path = tree_path(available_tree)
|
|
200
|
-
new_path = File.join(Dir.home, 'work', subtree_name)
|
|
201
|
-
|
|
202
|
-
if available_tree != subtree_name
|
|
203
|
-
puts "Renaming worktree '#{available_tree}' to '#{subtree_name}'..."
|
|
204
|
-
FileUtils.mkdir_p(File.dirname(new_path))
|
|
205
|
-
result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
|
|
206
|
-
unless result
|
|
207
|
-
puts "ERROR: Failed to rename worktree"
|
|
208
|
-
return false
|
|
209
|
-
end
|
|
210
|
-
clear_worktree_cache
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
final_tree_name = subtree_name
|
|
214
|
-
final_tree_path = new_path
|
|
215
|
-
else
|
|
216
|
-
# No available worktree, create a new one
|
|
217
|
-
puts "Creating new worktree for '#{task_name}'..."
|
|
218
|
-
new_path = File.join(Dir.home, 'work', subtree_name)
|
|
219
|
-
|
|
220
|
-
# Create worktree from main branch (detached to avoid branch conflicts)
|
|
221
|
-
FileUtils.mkdir_p(File.dirname(new_path))
|
|
222
|
-
result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
|
|
223
|
-
unless result
|
|
224
|
-
puts "ERROR: Failed to create worktree"
|
|
225
|
-
return false
|
|
226
|
-
end
|
|
227
|
-
clear_worktree_cache
|
|
228
|
-
|
|
229
|
-
final_tree_name = subtree_name
|
|
230
|
-
final_tree_path = new_path
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# Associate task with tree
|
|
234
|
-
assign_task_to_tree(task_name, final_tree_name)
|
|
235
|
-
|
|
236
|
-
# Create/switch to tmux session
|
|
237
|
-
session_name = session_name_for(task_name)
|
|
238
|
-
|
|
239
|
-
Dir.chdir(final_tree_path)
|
|
240
|
-
hiiro.start_tmux_session(session_name)
|
|
241
|
-
|
|
242
|
-
save_task_metadata(task_name, tree: final_tree_name, session: session_name)
|
|
243
|
-
|
|
244
|
-
puts "Started task '#{task_name}' in worktree '#{final_tree_name}'"
|
|
245
|
-
true
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
# Start working on a task
|
|
249
|
-
def switch_task(task_name)
|
|
250
|
-
tree, task = assignments.find { |tree, task| task.start_with?(task_name) } || []
|
|
251
|
-
|
|
252
|
-
unless task
|
|
253
|
-
puts "No task matching #{task_name} found."
|
|
254
|
-
return false
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
switch_to_task(task, tree)
|
|
258
|
-
|
|
259
|
-
puts "Started task '#{task}' in tree '#{tree}'"
|
|
260
|
-
true
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Open an app window within the current tree
|
|
264
|
-
def open_app(app_name)
|
|
265
|
-
current = current_task
|
|
266
|
-
unless current
|
|
267
|
-
puts "ERROR: Not currently in a task session"
|
|
268
|
-
puts "Use 'h task start TASK_NAME' first"
|
|
269
|
-
return false
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
tree = current[:tree]
|
|
273
|
-
result = find_app_path(tree, app_name)
|
|
274
|
-
|
|
275
|
-
case result
|
|
276
|
-
in nil
|
|
277
|
-
puts "ERROR: App '#{app_name}' not found"
|
|
278
|
-
puts
|
|
279
|
-
list_apps(tree)
|
|
280
|
-
return false
|
|
281
|
-
in [:ambiguous, matches]
|
|
282
|
-
puts "ERROR: '#{app_name}' matches multiple apps:"
|
|
283
|
-
matches.each { |m| puts " #{m}" }
|
|
284
|
-
puts
|
|
285
|
-
puts "Be more specific."
|
|
286
|
-
return false
|
|
287
|
-
in [resolved_name, app_path]
|
|
288
|
-
# Create new tmux window with app directory as base
|
|
289
|
-
system('tmux', 'new-window', '-n', resolved_name, '-c', app_path)
|
|
290
|
-
puts "Opened '#{resolved_name}' in new window (#{app_path})"
|
|
291
|
-
true
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# Open an app window within the current tree
|
|
296
|
-
def cd_app(app_name=nil)
|
|
297
|
-
current = current_task
|
|
298
|
-
unless current
|
|
299
|
-
puts "ERROR: Not currently in a task session"
|
|
300
|
-
puts "Use 'h task start TASK_NAME' first"
|
|
301
|
-
return false
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
tree = current[:tree]
|
|
305
|
-
|
|
306
|
-
result = []
|
|
307
|
-
if app_name.to_s == ''
|
|
308
|
-
result = ['root', tree_path(tree)]
|
|
309
|
-
else
|
|
310
|
-
result = find_app_path(tree, app_name)
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
case result
|
|
314
|
-
in nil
|
|
315
|
-
puts "ERROR: App '#{app_name}' not found"
|
|
316
|
-
puts
|
|
317
|
-
list_apps(tree)
|
|
318
|
-
return false
|
|
319
|
-
in [:ambiguous, matches]
|
|
320
|
-
puts "ERROR: '#{app_name}' matches multiple apps:"
|
|
321
|
-
matches.each { |m| puts " #{m}" }
|
|
322
|
-
puts
|
|
323
|
-
puts "Be more specific."
|
|
324
|
-
return false
|
|
325
|
-
in [resolved_name, app_path]
|
|
326
|
-
# Create new tmux window with app directory as base
|
|
327
|
-
pane = ENV['TMUX_PANE']
|
|
328
|
-
if pane
|
|
329
|
-
puts "PANE: #{pane}"
|
|
330
|
-
puts command: ['tmux', 'send-keys', '-t', pane, "cd #{app_path}\n"].join(' ')
|
|
331
|
-
system('tmux', 'send-keys', '-t', pane, "cd #{app_path}\n")
|
|
332
|
-
else
|
|
333
|
-
puts command: ['tmux', 'send-keys', "cd #{app_path}\n"].join(' ')
|
|
334
|
-
system('tmux', 'send-keys', "cd #{app_path}\n")
|
|
335
|
-
end
|
|
336
|
-
puts "Opened '#{resolved_name}' in new window (#{app_path})"
|
|
337
|
-
true
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Save current tmux session state
|
|
342
|
-
def save_current
|
|
343
|
-
current = current_task
|
|
344
|
-
unless current
|
|
345
|
-
puts "ERROR: Not currently in a task session"
|
|
346
|
-
return false
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
task_name = current[:task]
|
|
350
|
-
tree = current[:tree]
|
|
351
|
-
session = current[:session]
|
|
352
|
-
|
|
353
|
-
# Capture tmux window info
|
|
354
|
-
windows = capture_tmux_windows(session)
|
|
355
|
-
|
|
356
|
-
save_task_metadata(task_name,
|
|
357
|
-
tree: tree,
|
|
358
|
-
session: session,
|
|
359
|
-
windows: windows,
|
|
360
|
-
saved_at: Time.now.iso8601
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
puts "Saved task '#{task_name}' state (#{windows.count} windows)"
|
|
364
|
-
true
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
# Show current task status
|
|
368
|
-
def status
|
|
369
|
-
current = current_task
|
|
370
|
-
unless current
|
|
371
|
-
puts "Not currently in a task session"
|
|
372
|
-
return
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
puts "Current task: #{current[:task]}"
|
|
376
|
-
puts "Worktree: #{current[:tree]}"
|
|
377
|
-
puts "Path: #{tree_path(current[:tree])}"
|
|
378
|
-
puts "Session: #{current[:session]}"
|
|
379
|
-
|
|
380
|
-
meta = task_metadata(current[:task])
|
|
381
|
-
if meta && meta['saved_at']
|
|
382
|
-
puts "Last saved: #{meta['saved_at']}"
|
|
383
|
-
end
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
# Stop working on current task (disassociate from worktree)
|
|
387
|
-
def stop_current
|
|
388
|
-
current = current_task
|
|
389
|
-
unless current
|
|
390
|
-
puts "Not currently in a task session"
|
|
391
|
-
return false
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
stop_task(current[:task])
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
# Stop working on a task (disassociate from worktree)
|
|
398
|
-
def stop_task(task_name)
|
|
399
|
-
tree = tree_for_task(task_name)
|
|
400
|
-
task_name = task_for_tree(tree)
|
|
401
|
-
|
|
402
|
-
unassign_task_from_tree(tree)
|
|
403
|
-
puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
|
|
404
|
-
true
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
# Subtask management
|
|
408
|
-
def subtask_help
|
|
409
|
-
puts "Usage: h task subtask <subcommand> [args]"
|
|
410
|
-
puts " h subtask <subcommand> [args]"
|
|
411
|
-
puts
|
|
412
|
-
puts "Subcommands:"
|
|
413
|
-
puts " list, ls List subtasks for current task"
|
|
414
|
-
puts " new SUBTASK_NAME Start a new subtask (creates worktree and session)"
|
|
415
|
-
puts " switch SUBTASK_NAME Switch to subtask's tmux session"
|
|
416
|
-
puts " app APP_NAME Open a tmux window for an app in current subtask"
|
|
417
|
-
puts " apps List configured apps from apps.yml"
|
|
418
|
-
puts " cd [APP_NAME] Change directory to app in current subtask"
|
|
419
|
-
puts " path [APP_NAME] Print app path in current subtask"
|
|
420
|
-
puts " status, st Show current subtask status"
|
|
421
|
-
puts " save Save current subtask session info"
|
|
422
|
-
puts " stop [SUBTASK_NAME] Stop working on current/named subtask"
|
|
423
|
-
puts " current Print current subtask name"
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def handle_subtask(*args)
|
|
427
|
-
case args
|
|
428
|
-
in []
|
|
429
|
-
subtask_help
|
|
430
|
-
in ['list'] | ['ls']
|
|
431
|
-
list_subtasks
|
|
432
|
-
in ['new', subtask_name]
|
|
433
|
-
new_subtask(subtask_name)
|
|
434
|
-
in ['switch']
|
|
435
|
-
switch_subtask
|
|
436
|
-
in ['switch', subtask_name]
|
|
437
|
-
switch_subtask(subtask_name)
|
|
438
|
-
in ['app', app_name]
|
|
439
|
-
subtask_open_app(app_name)
|
|
440
|
-
in ['apps']
|
|
441
|
-
list_configured_apps
|
|
442
|
-
in ['cd', *cd_args]
|
|
443
|
-
subtask_cd_app(*cd_args)
|
|
444
|
-
in ['path']
|
|
445
|
-
subtask_app_path
|
|
446
|
-
in ['path', app_name]
|
|
447
|
-
subtask_app_path(app_name)
|
|
448
|
-
in ['status'] | ['st']
|
|
449
|
-
subtask_status
|
|
450
|
-
in ['save']
|
|
451
|
-
save_subtask
|
|
452
|
-
in ['stop']
|
|
453
|
-
stop_subtask
|
|
454
|
-
in ['stop', subtask_name]
|
|
455
|
-
stop_subtask(subtask_name)
|
|
456
|
-
in ['current']
|
|
457
|
-
print current_subtask_name
|
|
458
|
-
else
|
|
459
|
-
puts "Unknown subtask command: #{args.inspect}"
|
|
460
|
-
subtask_help
|
|
461
|
-
end
|
|
462
|
-
end
|
|
463
|
-
|
|
464
|
-
def list_subtasks
|
|
465
|
-
current = current_task
|
|
466
|
-
unless current
|
|
467
|
-
puts "ERROR: Not currently in a task session"
|
|
468
|
-
return false
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
parent_task = current[:task]
|
|
472
|
-
# Handle if we're in a subtask - get the parent
|
|
473
|
-
if parent_task.include?('/')
|
|
474
|
-
parent_task = parent_task.split('/').first
|
|
475
|
-
end
|
|
476
|
-
subtasks = subtasks_for_task(parent_task)
|
|
477
|
-
|
|
478
|
-
puts "Subtasks for '#{parent_task}':"
|
|
479
|
-
puts
|
|
480
|
-
|
|
481
|
-
# Always show the parent (main) task first
|
|
482
|
-
parent_tree = tree_for_task(parent_task)
|
|
483
|
-
current_tree = current[:tree]
|
|
484
|
-
parent_marker = (parent_tree && parent_tree == current_tree) ? "*" : " "
|
|
485
|
-
parent_branch = worktree_branch(parent_tree) if parent_tree
|
|
486
|
-
branch_info = parent_branch ? " branch: #{parent_branch}" : ""
|
|
487
|
-
puts format("%s %-25s tree: %-15s%s", parent_marker, "(main)", parent_tree || '(none)', branch_info)
|
|
488
|
-
|
|
489
|
-
subtasks.each do |subtask|
|
|
490
|
-
marker = (subtask['worktree'] == current_tree) ? "*" : " "
|
|
491
|
-
st_branch = worktree_branch(subtask['worktree']) if subtask['worktree']
|
|
492
|
-
st_branch_info = st_branch ? " branch: #{st_branch}" : ""
|
|
493
|
-
puts format("%s %-25s tree: %-15s created: %s%s",
|
|
494
|
-
marker,
|
|
495
|
-
subtask['name'],
|
|
496
|
-
subtask['worktree'] || '(none)',
|
|
497
|
-
subtask['created_at']&.split('T')&.first || '?',
|
|
498
|
-
st_branch_info
|
|
499
|
-
)
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
def new_subtask(subtask_name)
|
|
504
|
-
current = current_task
|
|
505
|
-
unless current
|
|
506
|
-
puts "ERROR: Not currently in a task session"
|
|
507
|
-
puts "Use 'h task start TASK_NAME' first"
|
|
508
|
-
return false
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
parent_task = current[:task]
|
|
512
|
-
|
|
513
|
-
if parent_task.include?('/')
|
|
514
|
-
parent_task = parent_task.split('/').first
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
parent_dir =
|
|
518
|
-
if worktree_info.key?(parent_task)
|
|
519
|
-
"#{parent_task}_subtasks"
|
|
520
|
-
else
|
|
521
|
-
parent_task
|
|
522
|
-
end
|
|
523
|
-
full_subtask_name = "#{parent_dir}/#{subtask_name}"
|
|
524
|
-
|
|
525
|
-
# Check if subtask already exists
|
|
526
|
-
existing = subtasks_for_task(parent_task).find { |s| s['name'] == subtask_name }
|
|
527
|
-
if existing
|
|
528
|
-
puts "Subtask '#{subtask_name}' already exists for task '#{parent_task}'"
|
|
529
|
-
puts "Switching to existing session..."
|
|
530
|
-
switch_subtask(subtask_name)
|
|
531
|
-
return true
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
# Create new worktree for subtask
|
|
535
|
-
puts "Creating new worktree for subtask '#{subtask_name}'..."
|
|
536
|
-
new_path = File.join(Dir.home, 'work', full_subtask_name)
|
|
537
|
-
|
|
538
|
-
result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
|
|
539
|
-
unless result
|
|
540
|
-
puts "ERROR: Failed to create worktree"
|
|
541
|
-
return false
|
|
542
|
-
end
|
|
543
|
-
clear_worktree_cache
|
|
544
|
-
|
|
545
|
-
# Associate subtask with tree
|
|
546
|
-
assign_task_to_tree(full_subtask_name, full_subtask_name)
|
|
547
|
-
|
|
548
|
-
# Record subtask in parent's metadata
|
|
549
|
-
add_subtask_to_task(parent_task, {
|
|
550
|
-
'name' => subtask_name,
|
|
551
|
-
'worktree' => full_subtask_name,
|
|
552
|
-
'session' => full_subtask_name,
|
|
553
|
-
'created_at' => Time.now.iso8601,
|
|
554
|
-
'active' => true
|
|
555
|
-
})
|
|
556
|
-
|
|
557
|
-
# Create/switch to tmux session
|
|
558
|
-
Dir.chdir(new_path)
|
|
559
|
-
hiiro.start_tmux_session(full_subtask_name)
|
|
560
|
-
|
|
561
|
-
puts "Started subtask '#{subtask_name}' for task '#{parent_task}'"
|
|
562
|
-
true
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
def switch_subtask(subtask_name = nil)
|
|
566
|
-
current = current_task
|
|
567
|
-
unless current
|
|
568
|
-
puts "ERROR: Not currently in a task session"
|
|
569
|
-
puts "Use 'h task start TASK_NAME' first"
|
|
570
|
-
return false
|
|
571
|
-
end
|
|
572
|
-
|
|
573
|
-
parent_task = current[:task]
|
|
574
|
-
# Handle if we're in a subtask - get the parent
|
|
575
|
-
if parent_task.include?('/')
|
|
576
|
-
parent_task = parent_task.split('/').first
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
# If no subtask name, use interactive selection
|
|
580
|
-
if subtask_name.nil? || subtask_name.empty?
|
|
581
|
-
subtask_name = select_subtask_interactive(parent_task)
|
|
582
|
-
return false unless subtask_name
|
|
583
|
-
end
|
|
584
|
-
|
|
585
|
-
# Switch to parent task if "main" or "parent"
|
|
586
|
-
if subtask_name == 'main' || subtask_name == 'parent'
|
|
587
|
-
session_name = session_name_for(parent_task)
|
|
588
|
-
tree_name = tree_for_task(parent_task)
|
|
589
|
-
|
|
590
|
-
unless tree_name
|
|
591
|
-
puts "ERROR: Parent task '#{parent_task}' has no worktree"
|
|
592
|
-
return false
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL)
|
|
596
|
-
|
|
597
|
-
if session_exists
|
|
598
|
-
hiiro.start_tmux_session(session_name)
|
|
599
|
-
else
|
|
600
|
-
path = tree_path(tree_name)
|
|
601
|
-
if Dir.exist?(path)
|
|
602
|
-
Dir.chdir(path)
|
|
603
|
-
hiiro.start_tmux_session(session_name)
|
|
604
|
-
else
|
|
605
|
-
puts "ERROR: Worktree path '#{path}' does not exist"
|
|
606
|
-
return false
|
|
607
|
-
end
|
|
608
|
-
end
|
|
609
|
-
|
|
610
|
-
puts "Switched to parent task '#{parent_task}'"
|
|
611
|
-
return true
|
|
612
|
-
end
|
|
613
|
-
|
|
614
|
-
subtask = find_subtask(parent_task, subtask_name)
|
|
615
|
-
unless subtask
|
|
616
|
-
puts "Subtask '#{subtask_name}' not found for task '#{parent_task}'"
|
|
617
|
-
puts
|
|
618
|
-
list_subtasks
|
|
619
|
-
return false
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
session_name = subtask['session'] || "#{parent_task}/#{subtask_name}"
|
|
623
|
-
tree_name = subtask['worktree'] || session_name
|
|
624
|
-
|
|
625
|
-
# Check if session exists
|
|
626
|
-
session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL)
|
|
627
|
-
|
|
628
|
-
if session_exists
|
|
629
|
-
hiiro.start_tmux_session(session_name)
|
|
630
|
-
else
|
|
631
|
-
# Create new session in the worktree path
|
|
632
|
-
path = tree_path(tree_name)
|
|
633
|
-
if Dir.exist?(path)
|
|
634
|
-
Dir.chdir(path)
|
|
635
|
-
hiiro.start_tmux_session(session_name)
|
|
636
|
-
else
|
|
637
|
-
puts "ERROR: Worktree path '#{path}' does not exist"
|
|
638
|
-
return false
|
|
639
|
-
end
|
|
640
|
-
end
|
|
641
|
-
|
|
642
|
-
puts "Switched to subtask '#{subtask_name}'"
|
|
643
|
-
true
|
|
644
|
-
end
|
|
645
|
-
|
|
646
|
-
# Interactive subtask selection using sk
|
|
647
|
-
def select_subtask_interactive(parent_task)
|
|
648
|
-
subtasks = subtasks_for_task(parent_task)
|
|
649
|
-
parent_tree = tree_for_task(parent_task)
|
|
650
|
-
|
|
651
|
-
# Build selection lines: include parent (main) and all subtasks
|
|
652
|
-
lines = []
|
|
653
|
-
lines << "(main)" if parent_tree
|
|
654
|
-
subtasks.each do |subtask|
|
|
655
|
-
next unless subtask['active']
|
|
656
|
-
lines << subtask['name']
|
|
657
|
-
end
|
|
658
|
-
|
|
659
|
-
if lines.empty?
|
|
660
|
-
puts "No subtasks to switch to."
|
|
661
|
-
return nil
|
|
662
|
-
end
|
|
663
|
-
|
|
664
|
-
choice = Hiiro::Sk.select(lines)
|
|
665
|
-
return nil unless choice
|
|
666
|
-
return 'main' if choice == '(main)'
|
|
667
|
-
|
|
668
|
-
choice
|
|
669
|
-
end
|
|
670
|
-
|
|
671
|
-
# Show status for the current subtask
|
|
672
|
-
def subtask_status
|
|
673
|
-
current = current_task
|
|
674
|
-
unless current
|
|
675
|
-
puts "Not currently in a task session"
|
|
676
|
-
return
|
|
677
|
-
end
|
|
678
|
-
|
|
679
|
-
parent_task = parent_task_name(current[:task])
|
|
680
|
-
subtask_name = current_subtask_name_from(current[:task])
|
|
681
|
-
|
|
682
|
-
puts "Parent task: #{parent_task}"
|
|
683
|
-
if subtask_name
|
|
684
|
-
puts "Subtask: #{subtask_name}"
|
|
685
|
-
else
|
|
686
|
-
puts "Subtask: (main)"
|
|
687
|
-
end
|
|
688
|
-
puts "Worktree: #{current[:tree]}"
|
|
689
|
-
puts "Path: #{tree_path(current[:tree])}"
|
|
690
|
-
puts "Session: #{current[:session]}"
|
|
691
|
-
end
|
|
692
|
-
|
|
693
|
-
# Save current subtask tmux session state
|
|
694
|
-
def save_subtask
|
|
695
|
-
current = current_task
|
|
696
|
-
unless current
|
|
697
|
-
puts "ERROR: Not currently in a task session"
|
|
698
|
-
return false
|
|
699
|
-
end
|
|
700
|
-
|
|
701
|
-
parent_task = parent_task_name(current[:task])
|
|
702
|
-
session = current[:session]
|
|
703
|
-
|
|
704
|
-
windows = capture_tmux_windows(session)
|
|
705
|
-
|
|
706
|
-
save_task_metadata(parent_task,
|
|
707
|
-
tree: current[:tree],
|
|
708
|
-
session: session,
|
|
709
|
-
windows: windows,
|
|
710
|
-
saved_at: Time.now.iso8601
|
|
711
|
-
)
|
|
712
|
-
|
|
713
|
-
puts "Saved subtask session state (#{windows.count} windows)"
|
|
714
|
-
true
|
|
715
|
-
end
|
|
716
|
-
|
|
717
|
-
# Stop a subtask (remove from parent's subtask list, unassign tree)
|
|
718
|
-
def stop_subtask(subtask_name = nil)
|
|
719
|
-
current = current_task
|
|
720
|
-
unless current
|
|
721
|
-
puts "Not currently in a task session"
|
|
722
|
-
return false
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
parent_task = parent_task_name(current[:task])
|
|
726
|
-
|
|
727
|
-
if subtask_name.nil?
|
|
728
|
-
# Stop the current subtask
|
|
729
|
-
subtask_name = current_subtask_name_from(current[:task])
|
|
730
|
-
unless subtask_name
|
|
731
|
-
puts "Not currently in a subtask (in main task)"
|
|
732
|
-
return false
|
|
733
|
-
end
|
|
734
|
-
end
|
|
735
|
-
|
|
736
|
-
subtask = find_subtask(parent_task, subtask_name)
|
|
737
|
-
unless subtask
|
|
738
|
-
puts "Subtask '#{subtask_name}' not found for task '#{parent_task}'"
|
|
739
|
-
return false
|
|
740
|
-
end
|
|
741
|
-
|
|
742
|
-
tree_name = subtask['worktree']
|
|
743
|
-
if tree_name
|
|
744
|
-
unassign_task_from_tree(tree_name)
|
|
745
|
-
end
|
|
746
|
-
|
|
747
|
-
# Mark subtask as inactive in parent metadata
|
|
748
|
-
meta = task_metadata(parent_task) || {}
|
|
749
|
-
if meta['subtasks']
|
|
750
|
-
meta['subtasks'].each do |s|
|
|
751
|
-
if s['name'].start_with?(subtask_name)
|
|
752
|
-
s['active'] = false
|
|
753
|
-
end
|
|
754
|
-
end
|
|
755
|
-
FileUtils.mkdir_p(task_dir)
|
|
756
|
-
File.write(task_metadata_file(parent_task), YAML.dump(meta))
|
|
757
|
-
end
|
|
758
|
-
|
|
759
|
-
puts "Stopped subtask '#{subtask_name}' (worktree available for reuse)"
|
|
760
|
-
true
|
|
761
|
-
end
|
|
762
|
-
|
|
763
|
-
# Print the current subtask name
|
|
764
|
-
def current_subtask_name
|
|
765
|
-
current = current_task
|
|
766
|
-
return nil unless current
|
|
767
|
-
|
|
768
|
-
subtask_name = current_subtask_name_from(current[:task])
|
|
769
|
-
subtask_name || current[:task]
|
|
770
|
-
end
|
|
771
|
-
|
|
772
|
-
# Open an app window scoped to the current subtask's tree
|
|
773
|
-
def subtask_open_app(app_name)
|
|
774
|
-
open_app(app_name)
|
|
775
|
-
end
|
|
776
|
-
|
|
777
|
-
# cd to app in current subtask's tree
|
|
778
|
-
def subtask_cd_app(*args)
|
|
779
|
-
cd_app(*args)
|
|
780
|
-
end
|
|
781
|
-
|
|
782
|
-
# Print app path in current subtask's tree
|
|
783
|
-
def subtask_app_path(app_name = nil, task: nil)
|
|
784
|
-
app_path(app_name, task: task || current_task&.dig(:task))
|
|
785
|
-
end
|
|
786
|
-
|
|
787
|
-
private
|
|
788
|
-
|
|
789
|
-
def subtasks_for_task(task_name)
|
|
790
|
-
meta = task_metadata(task_name)
|
|
791
|
-
return [] unless meta
|
|
792
|
-
meta['subtasks'] || []
|
|
793
|
-
end
|
|
794
|
-
|
|
795
|
-
def find_subtask(parent_task, subtask_name)
|
|
796
|
-
subtasks = subtasks_for_task(parent_task)
|
|
797
|
-
subtasks.find { |s| s['name'].start_with?(subtask_name) }
|
|
798
|
-
end
|
|
799
|
-
|
|
800
|
-
def add_subtask_to_task(parent_task, subtask_data)
|
|
801
|
-
meta = task_metadata(parent_task) || {}
|
|
802
|
-
meta['subtasks'] ||= []
|
|
803
|
-
meta['subtasks'] << subtask_data
|
|
804
|
-
FileUtils.mkdir_p(task_dir)
|
|
805
|
-
File.write(task_metadata_file(parent_task), YAML.dump(meta))
|
|
806
|
-
end
|
|
807
|
-
|
|
808
|
-
# Extract parent task name from a potentially nested task/subtask name
|
|
809
|
-
def parent_task_name(task_name)
|
|
810
|
-
task_name.include?('/') ? task_name.split('/').first : task_name
|
|
811
|
-
end
|
|
812
|
-
|
|
813
|
-
# Extract subtask name from a nested task name (returns nil if main)
|
|
814
|
-
def current_subtask_name_from(task_name)
|
|
815
|
-
return nil unless task_name.include?('/')
|
|
816
|
-
parts = task_name.split('/', 2)
|
|
817
|
-
parts.last == 'main' ? nil : parts.last
|
|
818
|
-
end
|
|
819
|
-
|
|
820
|
-
public
|
|
821
|
-
|
|
822
|
-
def list_configured_apps
|
|
823
|
-
if apps_config.any?
|
|
824
|
-
puts "Configured apps (#{apps_config_file}):"
|
|
825
|
-
puts
|
|
826
|
-
apps_config.each do |name, path|
|
|
827
|
-
puts format(" %-20s => %s", name, path)
|
|
828
|
-
end
|
|
829
|
-
else
|
|
830
|
-
puts "No apps configured."
|
|
831
|
-
puts
|
|
832
|
-
puts "Create #{apps_config_file} with format:"
|
|
833
|
-
puts " app_name: relative/path/from/repo"
|
|
834
|
-
puts
|
|
835
|
-
puts "Example:"
|
|
836
|
-
puts " partners: partners/partners"
|
|
837
|
-
puts " admin: admin_portal/admin"
|
|
838
|
-
end
|
|
839
|
-
end
|
|
840
|
-
|
|
841
|
-
def app_path(app_name, task: nil)
|
|
842
|
-
tree_root = task ? tree_path(tree_for_task(task)) : `git rev-parse --show-toplevel`.strip
|
|
843
|
-
|
|
844
|
-
if app_name.nil?
|
|
845
|
-
print tree_root
|
|
846
|
-
exit 0
|
|
847
|
-
end
|
|
848
|
-
|
|
849
|
-
matching_apps = find_all_apps(app_name)
|
|
850
|
-
longest_app_name = printable_apps.keys.max_by(&:length).length + 2
|
|
851
|
-
|
|
852
|
-
case matching_apps.count
|
|
853
|
-
when 0
|
|
854
|
-
puts "ERROR: No matches found"
|
|
855
|
-
puts
|
|
856
|
-
puts "Possible Apps:"
|
|
857
|
-
puts printable_apps.keys.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
|
|
858
|
-
exit 1
|
|
859
|
-
when 1
|
|
860
|
-
print File.join(tree_root, apps_config[matching_apps.first])
|
|
861
|
-
exit 0
|
|
862
|
-
else
|
|
863
|
-
puts "Multiple matches found:"
|
|
864
|
-
puts matching_apps.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
|
|
865
|
-
exit 1
|
|
866
|
-
end
|
|
867
|
-
end
|
|
868
|
-
|
|
869
|
-
private
|
|
870
|
-
|
|
871
|
-
def printable_apps
|
|
872
|
-
apps_config.transform_keys(&:to_s)
|
|
873
|
-
end
|
|
874
|
-
|
|
875
|
-
# Find worktrees using git worktree list
|
|
876
|
-
def trees
|
|
877
|
-
worktree_info.keys.sort
|
|
878
|
-
end
|
|
879
|
-
|
|
880
|
-
# Parse git worktree list output into { name => { path:, branch: } } hash
|
|
881
|
-
def worktree_details
|
|
882
|
-
@worktree_details ||= begin
|
|
883
|
-
output = `git -C #{main_repo_path} worktree list --porcelain 2>/dev/null`
|
|
884
|
-
details = {}
|
|
885
|
-
current_path = nil
|
|
886
|
-
|
|
887
|
-
work_dir = File.join(Dir.home, 'work')
|
|
888
|
-
work_prefix = work_dir + '/'
|
|
889
|
-
|
|
890
|
-
output.lines.each do |line|
|
|
891
|
-
line = line.strip
|
|
892
|
-
if line.start_with?('worktree ')
|
|
893
|
-
current_path = line.sub('worktree ', '')
|
|
894
|
-
elsif line == 'bare'
|
|
895
|
-
# Skip bare repo
|
|
896
|
-
current_path = nil
|
|
897
|
-
elsif line.start_with?('branch ') || line == 'detached'
|
|
898
|
-
branch = if line.start_with?('branch ')
|
|
899
|
-
line.sub('branch refs/heads/', '')
|
|
900
|
-
else
|
|
901
|
-
'(detached)'
|
|
902
|
-
end
|
|
903
|
-
|
|
904
|
-
# Capture worktree (both named branches and detached HEAD)
|
|
905
|
-
if current_path && current_path != main_repo_path
|
|
906
|
-
# Use relative path from ~/work/ to support nested worktrees
|
|
907
|
-
# e.g. ~/work/my-task/main -> "my-task/main"
|
|
908
|
-
name = if current_path.start_with?(work_prefix)
|
|
909
|
-
current_path.sub(work_prefix, '')
|
|
910
|
-
else
|
|
911
|
-
File.basename(current_path)
|
|
912
|
-
end
|
|
913
|
-
details[name] = { path: current_path, branch: branch }
|
|
914
|
-
end
|
|
915
|
-
current_path = nil
|
|
916
|
-
end
|
|
917
|
-
end
|
|
918
|
-
|
|
919
|
-
details
|
|
920
|
-
end
|
|
921
|
-
end
|
|
922
|
-
|
|
923
|
-
# Backward-compatible { name => path } hash
|
|
924
|
-
def worktree_info
|
|
925
|
-
@worktree_info ||= worktree_details.transform_values { |v| v[:path] }
|
|
926
|
-
end
|
|
927
|
-
|
|
928
|
-
# Get branch name for a worktree
|
|
929
|
-
def worktree_branch(tree_name)
|
|
930
|
-
worktree_details.dig(tree_name, :branch)
|
|
931
|
-
end
|
|
932
|
-
|
|
933
|
-
def clear_worktree_cache
|
|
934
|
-
@worktree_details = nil
|
|
935
|
-
@worktree_info = nil
|
|
936
|
-
end
|
|
937
|
-
|
|
938
|
-
# Get the main repo path (where we run git worktree commands from)
|
|
939
|
-
def main_repo_path
|
|
940
|
-
File.join(Dir.home, 'work', '.bare')
|
|
941
|
-
end
|
|
942
|
-
|
|
943
|
-
def tree_path(tree_name)
|
|
944
|
-
worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
|
|
945
|
-
end
|
|
946
|
-
|
|
947
|
-
# Find an available tree (one without an active task)
|
|
948
|
-
def find_available_tree
|
|
949
|
-
trees.find { |tree| task_for_tree(tree).nil? }
|
|
950
|
-
end
|
|
951
|
-
|
|
952
|
-
# Get the task currently assigned to a tree
|
|
953
|
-
def task_for_tree(tree_name)
|
|
954
|
-
assignments[tree_name]
|
|
955
|
-
end
|
|
956
|
-
|
|
957
|
-
# Get the tree a task is assigned to
|
|
958
|
-
def tree_for_task(task_name)
|
|
959
|
-
assignment_for_task(task_name)&.first
|
|
960
|
-
end
|
|
961
|
-
|
|
962
|
-
def assignment_for_task(partial)
|
|
963
|
-
assignments.find { |tree, task| task.start_with?(partial) }
|
|
964
|
-
end
|
|
965
|
-
|
|
966
|
-
def find_task(partial)
|
|
967
|
-
assignments.values.find { |task| task.start_with?(partial) }
|
|
968
|
-
end
|
|
969
|
-
|
|
970
|
-
# Assign a task to a tree
|
|
971
|
-
def assign_task_to_tree(task_name, tree_name)
|
|
972
|
-
data = assignments
|
|
973
|
-
data[tree_name] = task_name
|
|
974
|
-
save_assignments(data)
|
|
975
|
-
end
|
|
976
|
-
|
|
977
|
-
# Unassign task from tree
|
|
978
|
-
def unassign_task_from_tree(tree_name)
|
|
979
|
-
data = assignments.dup
|
|
980
|
-
data.delete(tree_name)
|
|
981
|
-
save_assignments(data)
|
|
982
|
-
end
|
|
983
|
-
|
|
984
|
-
# Tree -> Task assignments
|
|
985
|
-
def assignments
|
|
986
|
-
@assignments ||= load_assignments
|
|
987
|
-
end
|
|
988
|
-
|
|
989
|
-
def load_assignments
|
|
990
|
-
data = if File.exist?(assignments_file)
|
|
991
|
-
YAML.safe_load_file(assignments_file) || {}
|
|
992
|
-
else
|
|
993
|
-
{}
|
|
994
|
-
end
|
|
995
|
-
data
|
|
996
|
-
end
|
|
997
|
-
|
|
998
|
-
def save_assignments(data)
|
|
999
|
-
FileUtils.mkdir_p(task_dir)
|
|
1000
|
-
File.write(assignments_file, YAML.dump(data))
|
|
1001
|
-
@assignments = data
|
|
1002
|
-
end
|
|
1003
|
-
|
|
1004
|
-
def assignments_file
|
|
1005
|
-
File.join(task_dir, 'assignments.yml')
|
|
1006
|
-
end
|
|
1007
|
-
|
|
1008
|
-
# Task metadata
|
|
1009
|
-
def task_metadata(task_name)
|
|
1010
|
-
file = task_metadata_file(task_name)
|
|
1011
|
-
return nil unless File.exist?(file)
|
|
1012
|
-
YAML.safe_load_file(file)
|
|
1013
|
-
end
|
|
1014
|
-
|
|
1015
|
-
def save_task_metadata(task_name, **data)
|
|
1016
|
-
FileUtils.mkdir_p(task_dir)
|
|
1017
|
-
existing = task_metadata(task_name) || {}
|
|
1018
|
-
merged = existing.merge(data.transform_keys(&:to_s))
|
|
1019
|
-
File.write(task_metadata_file(task_name), YAML.dump(merged))
|
|
1020
|
-
end
|
|
1021
|
-
|
|
1022
|
-
def task_metadata_file(task_name)
|
|
1023
|
-
safe_name = task_name.gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
1024
|
-
File.join(task_dir, "task_#{safe_name}.yml")
|
|
1025
|
-
end
|
|
1026
|
-
|
|
1027
|
-
def task_dir
|
|
1028
|
-
File.join(Dir.home, '.config', 'hiiro', 'tasks')
|
|
1029
|
-
end
|
|
1030
|
-
|
|
1031
|
-
# Session name for a task
|
|
1032
|
-
def session_name_for(task_name)
|
|
1033
|
-
task_name
|
|
1034
|
-
end
|
|
1035
|
-
|
|
1036
|
-
# Detect current task from tmux session name
|
|
1037
|
-
def current_task
|
|
1038
|
-
return nil unless ENV['TMUX']
|
|
1039
|
-
|
|
1040
|
-
session = `tmux display-message -p '#S'`.strip
|
|
1041
|
-
|
|
1042
|
-
task_name = session
|
|
1043
|
-
tree = tree_for_task(task_name)
|
|
1044
|
-
|
|
1045
|
-
return nil unless tree
|
|
1046
|
-
|
|
1047
|
-
{ task: task_name, tree: tree, session: session }
|
|
1048
|
-
end
|
|
1049
|
-
|
|
1050
|
-
def switch_to_task(task_name, tree)
|
|
1051
|
-
session = session_name_for(task_name)
|
|
1052
|
-
tree_path = tree_path(tree)
|
|
1053
|
-
Dir.chdir(tree_path)
|
|
1054
|
-
hiiro.start_tmux_session(session)
|
|
1055
|
-
end
|
|
1056
|
-
|
|
1057
|
-
# Apps config from ~/.config/hiiro/apps.yml
|
|
1058
|
-
# Format: { "app_name" => "relative/path/from/repo/root" }
|
|
1059
|
-
def apps_config
|
|
1060
|
-
@apps_config ||= load_apps_config
|
|
1061
|
-
end
|
|
1062
|
-
|
|
1063
|
-
def load_apps_config
|
|
1064
|
-
return {} unless File.exist?(apps_config_file)
|
|
1065
|
-
YAML.safe_load_file(apps_config_file) || {}
|
|
1066
|
-
end
|
|
1067
|
-
|
|
1068
|
-
def apps_config_file
|
|
1069
|
-
File.join(Dir.home, '.config', 'hiiro', 'apps.yml')
|
|
1070
|
-
end
|
|
1071
|
-
|
|
1072
|
-
# Find app by partial match (must be unique)
|
|
1073
|
-
def find_app(partial)
|
|
1074
|
-
matches = find_all_apps(partial)
|
|
1075
|
-
|
|
1076
|
-
case matches.count
|
|
1077
|
-
when 0
|
|
1078
|
-
nil
|
|
1079
|
-
when 1
|
|
1080
|
-
matches.first
|
|
1081
|
-
else
|
|
1082
|
-
# Check for exact match among multiple partial matches
|
|
1083
|
-
exact = matches.find { |name| name == partial }
|
|
1084
|
-
exact ? [exact] : matches
|
|
1085
|
-
end
|
|
1086
|
-
end
|
|
1087
|
-
|
|
1088
|
-
def find_all_apps(partial)
|
|
1089
|
-
apps_config.keys.select { |name| name.start_with?(partial) }
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
# App discovery within a tree
|
|
1093
|
-
def find_app_path(tree, app_name)
|
|
1094
|
-
tree_root = tree_path(tree)
|
|
1095
|
-
|
|
1096
|
-
# First, check apps.yml config
|
|
1097
|
-
result = find_app(app_name)
|
|
1098
|
-
|
|
1099
|
-
case result
|
|
1100
|
-
when String
|
|
1101
|
-
# Single match - use configured path
|
|
1102
|
-
return [result, File.join(tree_root, apps_config[result])]
|
|
1103
|
-
when Array
|
|
1104
|
-
# Multiple matches - return them for error reporting
|
|
1105
|
-
return [:ambiguous, result]
|
|
1106
|
-
end
|
|
1107
|
-
|
|
1108
|
-
# Fallback: directory discovery if not in config
|
|
1109
|
-
# Look for exact match first
|
|
1110
|
-
exact = File.join(tree_root, app_name)
|
|
1111
|
-
return [app_name, exact] if Dir.exist?(exact)
|
|
1112
|
-
|
|
1113
|
-
# Look for nested app dirs (monorepo pattern: app/app)
|
|
1114
|
-
nested = File.join(tree_root, app_name, app_name)
|
|
1115
|
-
return [app_name, nested] if Dir.exist?(nested)
|
|
1116
|
-
|
|
1117
|
-
# Fuzzy match on directories
|
|
1118
|
-
pattern = File.join(tree_root, '*')
|
|
1119
|
-
match = Dir.glob(pattern).find { |path|
|
|
1120
|
-
File.basename(path).start_with?(app_name) && File.directory?(path)
|
|
1121
|
-
}
|
|
1122
|
-
return [File.basename(match), match] if match
|
|
1123
|
-
|
|
1124
|
-
nil
|
|
1125
|
-
end
|
|
1126
|
-
|
|
1127
|
-
def list_apps(tree)
|
|
1128
|
-
if apps_config.any?
|
|
1129
|
-
puts "Configured apps (from apps.yml):"
|
|
1130
|
-
apps_config.each do |name, path|
|
|
1131
|
-
puts format(" %-20s => %s", name, path)
|
|
1132
|
-
end
|
|
1133
|
-
else
|
|
1134
|
-
puts "No apps configured. Create ~/.config/hiiro/apps.yml"
|
|
1135
|
-
puts "Format:"
|
|
1136
|
-
puts " app_name: relative/path/from/repo"
|
|
1137
|
-
puts
|
|
1138
|
-
puts "Directories in tree:"
|
|
1139
|
-
tree_root = tree_path(tree)
|
|
1140
|
-
pattern = File.join(tree_root, '*')
|
|
1141
|
-
Dir.glob(pattern).select { |p| File.directory?(p) }.each do |path|
|
|
1142
|
-
puts " #{File.basename(path)}"
|
|
1143
|
-
end
|
|
1144
|
-
end
|
|
1145
|
-
end
|
|
1146
|
-
|
|
1147
|
-
# Capture tmux window state
|
|
1148
|
-
def capture_tmux_windows(session)
|
|
1149
|
-
output = `tmux list-windows -t #{session} -F '\#{window_index}:\#{window_name}:\#{pane_current_path}'`
|
|
1150
|
-
output.lines.map(&:strip).map { |line|
|
|
1151
|
-
idx, name, path = line.split(':')
|
|
1152
|
-
{ 'index' => idx, 'name' => name, 'path' => path }
|
|
1153
|
-
}
|
|
1154
|
-
end
|
|
1155
|
-
end
|
|
1156
|
-
end
|