ace-git-worktree 0.26.1 → 0.27.1

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 +62 -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 +153 -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 +186 -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 +196 -0
  66. data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
  67. data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +241 -0
  68. data/lib/ace/git/worktree/organisms/worktree_list_report.rb +315 -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,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.1"
7
7
  end
8
8
  end
9
9
  end
@@ -184,12 +184,24 @@ module Ace
184
184
  require_relative "worktree/models/worktree_config"
185
185
  require_relative "worktree/models/worktree_info"
186
186
  require_relative "worktree/models/worktree_metadata"
187
+ require_relative "worktree/models/task_index"
187
188
 
188
189
  require_relative "worktree/atoms/git_command"
189
190
  require_relative "worktree/atoms/path_expander"
190
191
  require_relative "worktree/atoms/slug_generator"
192
+ require_relative "worktree/atoms/task_id_extractor"
193
+ require_relative "worktree/atoms/duration_parser"
194
+ require_relative "worktree/atoms/relative_age_formatter"
195
+ require_relative "worktree/atoms/text_truncator"
191
196
 
192
197
  require_relative "worktree/molecules/config_loader"
198
+ require_relative "worktree/molecules/task_index_builder"
199
+ require_relative "worktree/molecules/worktree_probe"
200
+ require_relative "worktree/molecules/task_association_resolver"
201
+ require_relative "worktree/molecules/pr_status_resolver"
202
+ require_relative "worktree/molecules/recent_branch_counter"
203
+ require_relative "worktree/molecules/worktree_table_formatter"
204
+ require_relative "worktree/molecules/worktree_footer_formatter"
193
205
  require_relative "worktree/molecules/task_fetcher"
194
206
  require_relative "worktree/molecules/pull_request_creator"
195
207
  require_relative "worktree/molecules/pull_request_evidence_resolver"
@@ -203,6 +215,8 @@ module Ace
203
215
  require_relative "worktree/molecules/hook_executor"
204
216
 
205
217
  require_relative "worktree/organisms/task_worktree_orchestrator"
218
+ require_relative "worktree/organisms/worktree_probe_runner"
219
+ require_relative "worktree/organisms/worktree_list_report"
206
220
  require_relative "worktree/organisms/worktree_manager"
207
221
 
208
222
  require_relative "worktree/commands/create_command"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git-worktree
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.1
4
+ version: 0.27.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-12 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -199,10 +199,13 @@ files:
199
199
  - handbook/workflow-instructions/git/worktree-manage.wf.md
200
200
  - handbook/workflow-instructions/git/worktree.wf.md
201
201
  - lib/ace/git/worktree.rb
202
+ - lib/ace/git/worktree/atoms/duration_parser.rb
202
203
  - lib/ace/git/worktree/atoms/git_command.rb
203
204
  - lib/ace/git/worktree/atoms/path_expander.rb
205
+ - lib/ace/git/worktree/atoms/relative_age_formatter.rb
204
206
  - lib/ace/git/worktree/atoms/slug_generator.rb
205
207
  - lib/ace/git/worktree/atoms/task_id_extractor.rb
208
+ - lib/ace/git/worktree/atoms/text_truncator.rb
206
209
  - lib/ace/git/worktree/cli.rb
207
210
  - lib/ace/git/worktree/cli/commands/bootstrap.rb
208
211
  - lib/ace/git/worktree/cli/commands/cleanup.rb
@@ -222,6 +225,7 @@ files:
222
225
  - lib/ace/git/worktree/commands/remove_command.rb
223
226
  - lib/ace/git/worktree/commands/switch_command.rb
224
227
  - lib/ace/git/worktree/configuration.rb
228
+ - lib/ace/git/worktree/models/task_index.rb
225
229
  - lib/ace/git/worktree/models/worktree_config.rb
226
230
  - lib/ace/git/worktree/models/worktree_info.rb
227
231
  - lib/ace/git/worktree/models/worktree_metadata.rb
@@ -233,19 +237,28 @@ files:
233
237
  - lib/ace/git/worktree/molecules/current_task_linker.rb
234
238
  - lib/ace/git/worktree/molecules/hook_executor.rb
235
239
  - lib/ace/git/worktree/molecules/parent_task_resolver.rb
240
+ - lib/ace/git/worktree/molecules/pr_status_resolver.rb
236
241
  - lib/ace/git/worktree/molecules/pull_request_checkout_preparer.rb
237
242
  - lib/ace/git/worktree/molecules/pull_request_creator.rb
238
243
  - lib/ace/git/worktree/molecules/pull_request_evidence_resolver.rb
244
+ - lib/ace/git/worktree/molecules/recent_branch_counter.rb
245
+ - lib/ace/git/worktree/molecules/task_association_resolver.rb
239
246
  - lib/ace/git/worktree/molecules/task_committer.rb
240
247
  - lib/ace/git/worktree/molecules/task_fetcher.rb
248
+ - lib/ace/git/worktree/molecules/task_index_builder.rb
241
249
  - lib/ace/git/worktree/molecules/task_pusher.rb
242
250
  - lib/ace/git/worktree/molecules/task_status_updater.rb
243
251
  - lib/ace/git/worktree/molecules/toolchain_truster.rb
244
252
  - lib/ace/git/worktree/molecules/worktree_creator.rb
253
+ - lib/ace/git/worktree/molecules/worktree_footer_formatter.rb
245
254
  - lib/ace/git/worktree/molecules/worktree_lister.rb
255
+ - lib/ace/git/worktree/molecules/worktree_probe.rb
246
256
  - lib/ace/git/worktree/molecules/worktree_remover.rb
257
+ - lib/ace/git/worktree/molecules/worktree_table_formatter.rb
247
258
  - lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb
259
+ - lib/ace/git/worktree/organisms/worktree_list_report.rb
248
260
  - lib/ace/git/worktree/organisms/worktree_manager.rb
261
+ - lib/ace/git/worktree/organisms/worktree_probe_runner.rb
249
262
  - lib/ace/git/worktree/version.rb
250
263
  homepage: https://github.com/cs3b/ace
251
264
  licenses: