ace-git-worktree 0.26.0 → 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.
- checksums.yaml +4 -4
- data/.ace-defaults/git/worktree.yml +2 -2
- data/CHANGELOG.md +48 -0
- data/docs/getting-started.md +7 -4
- data/docs/usage.md +141 -24
- data/handbook/agents/worktree.ag.md +10 -6
- data/handbook/workflow-instructions/git/worktree-create.wf.md +3 -3
- data/handbook/workflow-instructions/git/worktree-manage.wf.md +6 -6
- data/handbook/workflow-instructions/git/worktree.wf.md +2 -2
- data/lib/ace/git/worktree/atoms/duration_parser.rb +81 -0
- data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
- data/lib/ace/git/worktree/atoms/task_id_extractor.rb +20 -1
- data/lib/ace/git/worktree/atoms/text_truncator.rb +67 -0
- data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
- data/lib/ace/git/worktree/cli.rb +1 -1
- data/lib/ace/git/worktree/commands/list_command.rb +90 -94
- data/lib/ace/git/worktree/models/task_index.rb +171 -0
- data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
- data/lib/ace/git/worktree/molecules/pr_status_resolver.rb +172 -0
- data/lib/ace/git/worktree/molecules/recent_branch_counter.rb +70 -0
- data/lib/ace/git/worktree/molecules/task_association_resolver.rb +121 -0
- data/lib/ace/git/worktree/molecules/task_committer.rb +31 -31
- data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
- data/lib/ace/git/worktree/molecules/worktree_footer_formatter.rb +43 -0
- data/lib/ace/git/worktree/molecules/worktree_lister.rb +22 -93
- data/lib/ace/git/worktree/molecules/worktree_probe.rb +176 -0
- data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +236 -0
- data/lib/ace/git/worktree/organisms/worktree_list_report.rb +277 -0
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +22 -20
- data/lib/ace/git/worktree/organisms/worktree_probe_runner.rb +320 -0
- data/lib/ace/git/worktree/version.rb +1 -1
- data/lib/ace/git/worktree.rb +14 -0
- 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
|
|
273
|
+
# result = manager.list_all(format: :json)
|
|
274
274
|
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|