ace-git-worktree 0.26.1 → 0.27.0

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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/nav/protocols/skill-sources/ace-git-worktree.yml +0 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +0 -0
  4. data/CHANGELOG.md +41 -0
  5. data/LICENSE +0 -0
  6. data/README.md +0 -0
  7. data/Rakefile +0 -0
  8. data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
  9. data/docs/demo/ace-git-worktree-getting-started.tape.yml +0 -0
  10. data/docs/demo/fixtures/README.md +0 -0
  11. data/docs/demo/fixtures/sample.txt +0 -0
  12. data/docs/getting-started.md +7 -4
  13. data/docs/handbook.md +0 -0
  14. data/docs/usage.md +141 -24
  15. data/handbook/agents/worktree.ag.md +10 -6
  16. data/handbook/skills/as-git-worktree/SKILL.md +0 -0
  17. data/handbook/skills/as-git-worktree-cleanup/SKILL.md +0 -0
  18. data/handbook/skills/as-git-worktree-create/SKILL.md +0 -0
  19. data/handbook/skills/as-git-worktree-manage/SKILL.md +0 -0
  20. data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
  21. data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
  22. data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
  23. data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
  24. data/lib/ace/git/worktree/atoms/git_command.rb +0 -0
  25. data/lib/ace/git/worktree/atoms/path_expander.rb +0 -0
  26. data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
  27. data/lib/ace/git/worktree/atoms/slug_generator.rb +0 -0
  28. data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
  29. data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
  30. data/lib/ace/git/worktree/cli/commands/bootstrap.rb +0 -0
  31. data/lib/ace/git/worktree/cli/commands/cleanup.rb +0 -0
  32. data/lib/ace/git/worktree/cli/commands/config.rb +0 -0
  33. data/lib/ace/git/worktree/cli/commands/create.rb +0 -0
  34. data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
  35. data/lib/ace/git/worktree/cli/commands/prune.rb +0 -0
  36. data/lib/ace/git/worktree/cli/commands/remove.rb +0 -0
  37. data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +0 -0
  38. data/lib/ace/git/worktree/cli/commands/switch.rb +0 -0
  39. data/lib/ace/git/worktree/cli.rb +1 -1
  40. data/lib/ace/git/worktree/commands/bootstrap_command.rb +0 -0
  41. data/lib/ace/git/worktree/commands/list_command.rb +90 -94
  42. data/lib/ace/git/worktree/commands/prune_command.rb +0 -0
  43. data/lib/ace/git/worktree/commands/remove_command.rb +0 -0
  44. data/lib/ace/git/worktree/commands/switch_command.rb +0 -0
  45. data/lib/ace/git/worktree/configuration.rb +0 -0
  46. data/lib/ace/git/worktree/models/task_index.rb +171 -0
  47. data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
  48. data/lib/ace/git/worktree/models/worktree_metadata.rb +0 -0
  49. data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +0 -0
  50. data/lib/ace/git/worktree/molecules/cleanup_applier.rb +0 -0
  51. data/lib/ace/git/worktree/molecules/config_loader.rb +0 -0
  52. data/lib/ace/git/worktree/molecules/current_task_linker.rb +0 -0
  53. data/lib/ace/git/worktree/molecules/hook_executor.rb +0 -0
  54. data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +0 -0
  55. data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
  56. data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
  57. data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
  58. data/lib/ace/git/worktree/molecules/task_fetcher.rb +0 -0
  59. data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
  60. data/lib/ace/git/worktree/molecules/task_pusher.rb +0 -0
  61. data/lib/ace/git/worktree/molecules/task_status_updater.rb +0 -0
  62. data/lib/ace/git/worktree/molecules/toolchain_truster.rb +0 -0
  63. data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
  64. data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
  65. data/lib/ace/git/worktree/molecules/worktree_probe.rb +176 -0
  66. data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
  67. data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +236 -0
  68. data/lib/ace/git/worktree/organisms/worktree_list_report.rb +277 -0
  69. data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
  70. data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
  71. data/lib/ace/git/worktree/version.rb +1 -1
  72. data/lib/ace/git/worktree.rb +14 -0
  73. metadata +15 -2
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../atoms/duration_parser"
6
+ require_relative "../molecules/worktree_lister"
7
+ require_relative "../molecules/task_index_builder"
8
+ require_relative "../molecules/task_association_resolver"
9
+ require_relative "../molecules/pr_status_resolver"
10
+ require_relative "../molecules/recent_branch_counter"
11
+ require_relative "../molecules/worktree_table_formatter"
12
+ require_relative "../molecules/worktree_footer_formatter"
13
+ require_relative "worktree_probe_runner"
14
+
15
+ module Ace
16
+ module Git
17
+ module Worktree
18
+ module Organisms
19
+ # Builds the `ace-git-worktree list` report end to end.
20
+ #
21
+ # Pipeline: inventory worktrees once -> build the `.ace-tasks/` index
22
+ # once -> fork-probe every worktree -> resolve task association ->
23
+ # classify activity from one clock snapshot -> resolve PRs (one bounded
24
+ # query) -> apply filters -> split active/inactive -> render.
25
+ #
26
+ # The report owns all ordering, clock reads, filtering, and rendering;
27
+ # probe children only report raw signals.
28
+ class WorktreeListReport
29
+ # @param lister [Molecules::WorktreeLister, nil]
30
+ # @param task_index_builder [Molecules::TaskIndexBuilder, nil]
31
+ # @param probe_runner [Organisms::WorktreeProbeRunner, nil] injectable
32
+ # @param pr_resolver [Molecules::PrStatusResolver, nil] injectable
33
+ # @param branch_counter [Molecules::RecentBranchCounter, nil] injectable
34
+ # @param clock [Proc, nil] injectable clock (`-> { Time }`) for tests
35
+ # @param project_root [String, nil]
36
+ # @param env [Hash]
37
+ # @param stdout [IO]
38
+ def initialize(lister: nil, task_index_builder: nil, probe_runner: nil, pr_resolver: nil,
39
+ branch_counter: nil, clock: nil, project_root: nil, env: ENV, stdout: $stdout)
40
+ @lister = lister || Molecules::WorktreeLister.new
41
+ @task_index_builder = task_index_builder
42
+ @probe_runner = probe_runner
43
+ @pr_resolver = pr_resolver
44
+ @branch_counter = branch_counter
45
+ @clock = clock
46
+ @project_root = project_root
47
+ @env = env
48
+ @stdout = stdout
49
+ end
50
+
51
+ # Run the report.
52
+ #
53
+ # @param options [Hash] parsed list options
54
+ # @return [Hash] `{success:, output:, worktrees:, statistics:}`
55
+ def run(options = {})
56
+ options = normalize_options(options)
57
+ inventory = @lister.inventory
58
+ unless inventory[:success]
59
+ return {success: false, error: inventory[:error], worktrees: [], statistics: {}}
60
+ end
61
+
62
+ raw_worktrees = inventory[:worktrees]
63
+ now = (@clock ? @clock.call : Time.now)
64
+ task_index = build_task_index
65
+ project_root = task_index.project_root || @project_root
66
+
67
+ probes = probe_worktrees(raw_worktrees, project_root)
68
+ enriched = enrich(raw_worktrees, probes, task_index, now, options)
69
+ selected = apply_filters(enriched, options)
70
+ active, inactive = selected.partition { |worktree| worktree.active_within_window }
71
+
72
+ statistics = build_statistics(selected, active, inactive, now, options, raw_worktrees)
73
+ output = render(selected, active, inactive, statistics, options)
74
+
75
+ {
76
+ success: true,
77
+ output: output,
78
+ worktrees: selected,
79
+ statistics: statistics
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def normalize_options(options)
86
+ window = options[:active_within]
87
+ window = Atoms::DurationParser.parse(window) if window.is_a?(String)
88
+ window ||= Atoms::DurationParser.default
89
+ options.merge(
90
+ format: (options[:format] || :table).to_sym,
91
+ window_hours: window[:hours],
92
+ window_label: window[:label],
93
+ full: options[:full] ? true : false,
94
+ quiet: options[:quiet] ? true : false
95
+ )
96
+ end
97
+
98
+ def build_task_index
99
+ if @task_index_builder
100
+ @task_index_builder.build
101
+ else
102
+ Molecules::TaskIndexBuilder.new(project_root: @project_root).build
103
+ end
104
+ end
105
+
106
+ def probe_worktrees(raw_worktrees, project_root)
107
+ runner = @probe_runner || WorktreeProbeRunner.new(
108
+ project_root: project_root,
109
+ base_ref: raw_worktrees.first&.commit
110
+ )
111
+ runner.run(raw_worktrees)
112
+ end
113
+
114
+ def enrich(raw_worktrees, probes, task_index, now, options)
115
+ resolver = Molecules::TaskAssociationResolver.new(task_index: task_index)
116
+ pr_resolver = @pr_resolver || Molecules::PrStatusResolver.new
117
+
118
+ raw_worktrees.each_with_index.map do |worktree, index|
119
+ probe = probes[index]
120
+ associated = resolver.resolve(worktree, probe)
121
+ last_commit = last_commit_hash(probe, now)
122
+ uncommitted = uncommitted_hash(probe)
123
+ active = active_worktree?(worktree, probe, options[:window_hours], now)
124
+ pr = pr_resolver.resolve(associated, associated.task_id)
125
+
126
+ associated.with_enrichment(
127
+ last_commit: last_commit,
128
+ uncommitted: uncommitted,
129
+ active_within_window: active,
130
+ pr: pr,
131
+ probe_error: probe.nil?
132
+ )
133
+ end
134
+ end
135
+
136
+ def last_commit_hash(probe, now)
137
+ return nil if probe.nil? || probe[:head_sha].nil?
138
+
139
+ committed_at = Time.at(probe[:head_committer_epoch].to_i).utc
140
+ {
141
+ sha: probe[:head_sha],
142
+ age_hours: ((now - committed_at) / 3600.0).round(2),
143
+ committed_at: committed_at.iso8601,
144
+ subject: probe[:head_subject].to_s
145
+ }
146
+ end
147
+
148
+ def uncommitted_hash(probe)
149
+ return nil if probe.nil?
150
+
151
+ staged = probe[:staged].to_i
152
+ unstaged = probe[:unstaged].to_i
153
+ untracked = probe[:untracked].to_i
154
+ total = staged + unstaged + untracked
155
+ return nil unless total.positive?
156
+
157
+ {staged: staged, unstaged: unstaged, untracked: untracked, total: total}
158
+ end
159
+
160
+ # A worktree is active inside the window when its HEAD commit is in
161
+ # the window or it has uncommitted changes. Bare worktrees are never
162
+ # active; probe failures classify as inactive.
163
+ def active_worktree?(worktree, probe, window_hours, now)
164
+ return false if worktree.bare || probe.nil?
165
+
166
+ committed_at = probe[:head_committer_epoch]
167
+ recent_commit = committed_at && now.to_i - committed_at.to_i <= (window_hours * 3600).to_i
168
+ dirty = uncommitted_hash(probe)
169
+ recent_commit || !dirty.nil?
170
+ end
171
+
172
+ def apply_filters(worktrees, options)
173
+ filtered = Array(worktrees)
174
+
175
+ if options[:task_associated] == true
176
+ filtered = filtered.select { |worktree| worktree.task_associated? }
177
+ elsif options[:task_associated] == false
178
+ filtered = filtered.reject { |worktree| worktree.task_associated? }
179
+ end
180
+
181
+ if options[:usable] == true
182
+ filtered = filtered.select(&:usable?)
183
+ elsif options[:usable] == false
184
+ filtered = filtered.reject(&:usable?)
185
+ end
186
+
187
+ if options[:search] && !options[:search].to_s.empty?
188
+ pattern = Regexp.new(options[:search].to_s, Regexp::IGNORECASE)
189
+ filtered = filtered.select { |worktree| worktree.branch&.match?(pattern) }
190
+ end
191
+
192
+ filtered
193
+ end
194
+
195
+ def build_statistics(selected, active, inactive, now, options, raw_worktrees)
196
+ {
197
+ total: selected.length,
198
+ active: active.length,
199
+ inactive: inactive.length,
200
+ task_associated: selected.count(&:task_associated?),
201
+ usable: selected.count(&:usable?),
202
+ recent_branches: recent_branch_count(raw_worktrees, now, options),
203
+ window_hours: options[:window_hours],
204
+ window_label: options[:window_label]
205
+ }
206
+ end
207
+
208
+ # Branch-based count; worktree filters never change it, so checkout
209
+ # exclusions use the complete inventory.
210
+ def recent_branch_count(raw_worktrees, now, options)
211
+ if @branch_counter
212
+ @branch_counter.call(worktrees: raw_worktrees, window_hours: options[:window_hours], now: now)
213
+ else
214
+ Molecules::RecentBranchCounter.new(
215
+ worktrees: raw_worktrees, window_hours: options[:window_hours], now: now
216
+ ).count
217
+ end
218
+ end
219
+
220
+ # --- Rendering ---
221
+
222
+ def render(selected, active, inactive, statistics, options)
223
+ case options[:format]
224
+ when :json
225
+ render_json(selected)
226
+ when :simple
227
+ render_simple(selected)
228
+ else
229
+ render_table(active, inactive, statistics, options)
230
+ end
231
+ end
232
+
233
+ def render_json(worktrees)
234
+ JSON.generate(worktrees.map(&:to_h))
235
+ end
236
+
237
+ def render_simple(worktrees)
238
+ @lister.format_for_display(worktrees, :simple)
239
+ end
240
+
241
+ def render_table(active, inactive, statistics, options)
242
+ footer = Molecules::WorktreeFooterFormatter.new(
243
+ window_label: statistics[:window_label],
244
+ full: options[:full]
245
+ )
246
+ table = Molecules::WorktreeTableFormatter.new(env: @env, full: options[:full], stdout: @stdout)
247
+
248
+ lines = [footer.header(active_count: active.length, total_count: statistics[:total])]
249
+
250
+ if statistics[:total].zero?
251
+ return "No worktrees found."
252
+ end
253
+
254
+ rows = options[:full] ? (active + inactive) : active
255
+ if rows.empty?
256
+ lines << footer.no_active_message
257
+ else
258
+ lines.concat(table.render(rows))
259
+ end
260
+
261
+ unless options[:quiet]
262
+ lines << ""
263
+ lines.concat(footer.lines(
264
+ inactive: inactive.length,
265
+ task_associated: statistics[:task_associated],
266
+ usable: statistics[:usable],
267
+ recent_branches: statistics[:recent_branches]
268
+ ))
269
+ end
270
+
271
+ lines.join("\n")
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -270,24 +270,33 @@ module Ace
270
270
  #
271
271
  # @example
272
272
  # result = manager.list_all
273
- # result = manager.list_all(format: :json, show_tasks: true)
273
+ # result = manager.list_all(format: :json)
274
274
  #
275
- # @option options [Symbol] :format Output format (:table, :json, :simple)
276
- # @option options [Boolean] :show_tasks Include task associations
275
+ # Programmatic inventory for internal consumers (overseer, prune,
276
+ # switch/remove hints). The user-facing enriched table is rendered by
277
+ # {WorktreeListReport}, not here.
278
+ #
279
+ # @option options [Symbol] :format Optional formatting (:simple, :json)
277
280
  # @option options [Boolean] :task_associated Filter by task association
278
281
  # @option options [Boolean] :usable Filter by usability
279
282
  # @option options [String] :search Filter by search pattern
280
283
  def list_all(options = {})
281
- task_filter_requested = !options[:task_associated].nil?
282
-
283
- # Get worktrees
284
- worktrees = if options[:show_tasks] || task_filter_requested
285
- @worktree_lister.list_with_tasks
286
- else
287
- @worktree_lister.list_all
284
+ inventory = @worktree_lister.inventory
285
+ unless inventory[:success]
286
+ # Programmatic inventory keeps its historical tolerant contract:
287
+ # outside a repository it returns an empty list, not an error.
288
+ return {
289
+ success: true,
290
+ worktrees: [],
291
+ formatted_output: nil,
292
+ statistics: @worktree_lister.get_statistics([]),
293
+ count: 0,
294
+ warning: inventory[:error]
295
+ }
288
296
  end
289
297
 
290
- # Apply filters
298
+ worktrees = inventory[:worktrees]
299
+
291
300
  if !options[:task_associated].nil? || !options[:usable].nil? || options[:search] || options[:task_id] || options[:pr_number]
292
301
  worktrees = @worktree_lister.filter(
293
302
  worktrees,
@@ -299,13 +308,7 @@ module Ace
299
308
  )
300
309
  end
301
310
 
302
- # Format output
303
- formatted_output = @worktree_lister.format_for_display(
304
- worktrees,
305
- options[:format] || :table
306
- )
307
-
308
- # Get statistics
311
+ formatted_output = options[:format] && @worktree_lister.format_for_display(worktrees, options[:format])
309
312
  stats = @worktree_lister.get_statistics(worktrees)
310
313
 
311
314
  {
@@ -441,8 +444,7 @@ module Ace
441
444
  # status = manager.get_status
442
445
  # puts "Total worktrees: #{status[:statistics][:total]}"
443
446
  def get_status
444
- # Get all worktrees with task associations
445
- worktrees = @worktree_lister.list_with_tasks
447
+ worktrees = @worktree_lister.list_all
446
448
  stats = @worktree_lister.get_statistics
447
449
 
448
450
  # Get task worktree status
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require_relative "../molecules/worktree_probe"
6
+
7
+ module Ace
8
+ module Git
9
+ module Worktree
10
+ module Organisms
11
+ # Runs one read-only probe per worktree in isolated child processes.
12
+ #
13
+ # Contract:
14
+ # - one child per worktree, at most {MAX_LIVE_CHILDREN} alive at once;
15
+ # - each child has a hard {PROBE_TIMEOUT} second budget; a timed-out
16
+ # child is killed together with its process group (no orphan `git`);
17
+ # - children never write repository state or the terminal, and results
18
+ # are stored by input index so output order never depends on child
19
+ # completion order;
20
+ # - a failed/timed-out probe yields nil (rendered as `?` by callers);
21
+ # - when `fork` is unavailable, probes run sequentially with the same
22
+ # per-probe timeout and the same result contract.
23
+ class WorktreeProbeRunner
24
+ # Maximum number of live probe children.
25
+ MAX_LIVE_CHILDREN = 16
26
+
27
+ # Per-probe hard timeout in seconds (implementation constant).
28
+ PROBE_TIMEOUT = 5
29
+
30
+ # @param project_root [String, nil] project root for probe task signals
31
+ # @param base_ref [String, nil] primary worktree head for merge-base diffs
32
+ # @param probe [Proc, nil] injectable probe callable for tests
33
+ # (receives a worktree, returns a Hash or nil); runs inside the child
34
+ # @param max_children [Integer] maximum live children
35
+ # @param timeout [Numeric] per-probe timeout in seconds
36
+ # @param fork_supported [Boolean, nil] force the sequential fallback
37
+ # when false (nil = detect POSIX fork)
38
+ def initialize(project_root: nil, base_ref: nil, probe: nil,
39
+ max_children: MAX_LIVE_CHILDREN, timeout: PROBE_TIMEOUT, fork_supported: nil)
40
+ @project_root = project_root
41
+ @base_ref = base_ref
42
+ @probe = probe
43
+ @max_children = max_children
44
+ @timeout = timeout
45
+ @fork_supported = fork_supported.nil? ? fork_supported? : fork_supported
46
+ end
47
+
48
+ # Probe every worktree.
49
+ #
50
+ # @param worktrees [Array<Models::WorktreeInfo>] worktrees in display order
51
+ # @return [Array<Hash, nil>] payload per worktree, aligned with input
52
+ def run(worktrees)
53
+ list = Array(worktrees)
54
+ return [] if list.empty?
55
+ return sequential_all(list) unless @fork_supported
56
+
57
+ begin
58
+ fork_all(list)
59
+ rescue NotImplementedError
60
+ # fork exists but is unavailable in this environment (e.g. a
61
+ # restricted container): fall back to sequential probes.
62
+ @fork_supported = false
63
+ sequential_all(list)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def fork_supported?
70
+ Process.respond_to?(:fork) && !Gem.win_platform?
71
+ end
72
+
73
+ # --- Forked pool ---
74
+
75
+ def fork_all(worktrees)
76
+ results = Array.new(worktrees.length)
77
+ pending = worktrees.each_with_index.map { |worktree, index| [index, worktree] }
78
+ active = {}
79
+
80
+ begin
81
+ until pending.empty? && active.empty?
82
+ start_children(pending, active) while active.length < @max_children && !pending.empty?
83
+ break if active.empty?
84
+
85
+ wait_for_results(active, results)
86
+ end
87
+ ensure
88
+ terminate_all(active, results)
89
+ end
90
+
91
+ results
92
+ end
93
+
94
+ def start_children(pending, active)
95
+ index, worktree = pending.shift
96
+ pipe_read, pipe_write = IO.pipe
97
+ flush_standard_streams
98
+ pid = fork do
99
+ child_process(pipe_read, pipe_write, worktree)
100
+ exit!(0)
101
+ end
102
+
103
+ pipe_write.close
104
+ pipe_read.binmode
105
+ active[pid] = {
106
+ index: index,
107
+ pipe: pipe_read,
108
+ buffer: +"",
109
+ deadline: monotonic_now + @timeout
110
+ }
111
+ end
112
+
113
+ # Body executed in the forked child. Never raises to the parent and
114
+ # never writes to the inherited terminal.
115
+ def child_process(pipe_read, pipe_write, worktree)
116
+ pipe_read.close
117
+ begin
118
+ Process.setpgid(0, 0)
119
+ rescue StandardError
120
+ # Process group isolation is best effort on exotic platforms.
121
+ end
122
+ begin
123
+ STDOUT.reopen(File::NULL)
124
+ STDERR.reopen(File::NULL)
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ payload = execute_probe(worktree)
130
+ pipe_write.write(JSON.generate(payload)) if payload
131
+ rescue Exception # rubocop:disable Lint/RescueException
132
+ nil
133
+ ensure
134
+ begin
135
+ pipe_write.close
136
+ rescue StandardError
137
+ nil
138
+ end
139
+ end
140
+
141
+ # Wait for at least one child to finish or for the nearest deadline.
142
+ def wait_for_results(active, results)
143
+ pipes = active.values.map { |job| job[:pipe] }
144
+ nearest_deadline = active.values.map { |job| job[:deadline] }.min
145
+ wait_seconds = [nearest_deadline - monotonic_now, 0].max
146
+
147
+ ready = IO.select(pipes, nil, nil, wait_seconds)
148
+
149
+ if ready.nil?
150
+ expire_timed_out(active, results)
151
+ else
152
+ ready.first.each { |pipe| drain_child_pipe(pipe, active, results) }
153
+ expire_timed_out(active, results)
154
+ end
155
+ end
156
+
157
+ # Read currently available bytes; on EOF parse and reap the child.
158
+ def drain_child_pipe(pipe, active, results)
159
+ job = active.values.find { |candidate| candidate[:pipe] == pipe }
160
+ return unless job
161
+
162
+ loop do
163
+ job[:buffer] << pipe.read_nonblock(65_536)
164
+ rescue IO::WaitReadable
165
+ return
166
+ rescue EOFError
167
+ finish_job(active, job, success: true, results: results)
168
+ return
169
+ end
170
+ end
171
+
172
+ def finish_job(active, job, success:, results:)
173
+ pid = active.key(job)
174
+ active.delete(pid) if pid
175
+
176
+ if success
177
+ results[job[:index]] = parse_payload(job[:buffer])
178
+ end
179
+
180
+ begin
181
+ job[:pipe].close unless job[:pipe].closed?
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ reap(pid) if pid
187
+ end
188
+
189
+ def expire_timed_out(active, results)
190
+ now = monotonic_now
191
+ active.values.each do |job|
192
+ next if job[:deadline] > now
193
+
194
+ pid = active.key(job)
195
+ next unless pid
196
+
197
+ terminate(pid)
198
+ results[job[:index]] = nil
199
+ begin
200
+ job[:pipe].close unless job[:pipe].closed?
201
+ rescue StandardError
202
+ nil
203
+ end
204
+ active.delete(pid)
205
+ end
206
+ end
207
+
208
+ # Kill a child's process group (TERM, then KILL) and reap it.
209
+ def terminate(pid)
210
+ terminate_process_group(pid, "TERM")
211
+ # Give the child a brief chance to exit cleanly.
212
+ deadline = monotonic_now + 0.2
213
+ loop do
214
+ break if reap_nonblocking(pid)
215
+ break if monotonic_now > deadline
216
+
217
+ sleep 0.01
218
+ end
219
+ terminate_process_group(pid, "KILL")
220
+ reap(pid)
221
+ rescue Errno::ESRCH
222
+ nil
223
+ end
224
+
225
+ def terminate_all(active, results)
226
+ active.each do |pid, job|
227
+ terminate(pid)
228
+ results[job[:index]] ||= nil
229
+ end
230
+ active.clear
231
+ end
232
+
233
+ def terminate_process_group(pid, signal)
234
+ Process.kill("-#{signal}", pid)
235
+ rescue Errno::ESRCH
236
+ # Group already gone.
237
+ nil
238
+ rescue StandardError
239
+ begin
240
+ Process.kill(signal, pid)
241
+ rescue StandardError
242
+ nil
243
+ end
244
+ end
245
+
246
+ def reap(pid)
247
+ Process.wait(pid)
248
+ rescue Errno::ECHILD
249
+ nil
250
+ end
251
+
252
+ def reap_nonblocking(pid)
253
+ reaped = Process.wait(pid, Process::WNOHANG)
254
+ !reaped.nil?
255
+ rescue Errno::ECHILD
256
+ true
257
+ end
258
+
259
+ # --- Sequential fallback ---
260
+
261
+ def sequential_all(worktrees)
262
+ worktrees.map do |worktree|
263
+ Timeout.timeout(@timeout) { normalize_payload(execute_probe(worktree)) }
264
+ rescue Timeout::Error, StandardError
265
+ nil
266
+ end
267
+ end
268
+
269
+ # --- Probe execution ---
270
+
271
+ def execute_probe(worktree)
272
+ if @probe
273
+ @probe.call(worktree)
274
+ else
275
+ Molecules::WorktreeProbe.new(project_root: @project_root, base_ref: @base_ref).probe(worktree.path)
276
+ end
277
+ end
278
+
279
+ def parse_payload(buffer)
280
+ return nil if buffer.to_s.strip.empty?
281
+
282
+ normalize_payload(JSON.parse(buffer))
283
+ rescue JSON::ParserError
284
+ nil
285
+ end
286
+
287
+ def normalize_payload(payload)
288
+ return nil unless payload.is_a?(Hash)
289
+
290
+ deep_symbolize(payload)
291
+ end
292
+
293
+ def deep_symbolize(value)
294
+ case value
295
+ when Hash
296
+ value.each_with_object({}) { |(key, item), memo| memo[key.to_sym] = deep_symbolize(item) }
297
+ when Array
298
+ value.map { |item| deep_symbolize(item) }
299
+ else
300
+ value
301
+ end
302
+ end
303
+
304
+ # Flush buffered parent output before forking so children never
305
+ # inherit (and re-emit) unflushed terminal output.
306
+ def flush_standard_streams
307
+ $stdout.flush
308
+ $stderr.flush
309
+ rescue StandardError
310
+ nil
311
+ end
312
+
313
+ def monotonic_now
314
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Worktree
6
- VERSION = "0.26.1"
6
+ VERSION = "0.27.0"
7
7
  end
8
8
  end
9
9
  end