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,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require_relative "../atoms/relative_age_formatter"
5
+ require_relative "pr_status_resolver"
6
+ require_relative "../atoms/text_truncator"
7
+
8
+ module Ace
9
+ module Git
10
+ module Worktree
11
+ module Molecules
12
+ # Renders the width-aware listing table.
13
+ #
14
+ # Each worktree renders as three lines: the aligned columns
15
+ # (Task/Branch/Age/Commit/Changes/PR), the never-truncated path
16
+ # indented to the Commit column, and a blank separator line, so each
17
+ # entry is visually grouped and the displayed path is always directly
18
+ # usable (copy/paste, cd).
19
+ #
20
+ # Layout targets (two-space gutters): Task = max(task cells, 4),
21
+ # Branch 25 (min 12), Age 5, Commit 24 (min 10), Changes 7, PR 11.
22
+ # When the target total exceeds the detected width, columns shrink in
23
+ # priority order Commit -> Branch down to their minima, then Commit is
24
+ # dropped. Column lines never exceed the detected width; `--full`
25
+ # prints absolute paths and is exempt from width limits.
26
+ class WorktreeTableFormatter
27
+ GUTTER = " "
28
+ COLUMNS_MIN_WIDTH = 40
29
+ DEFAULT_WIDTH = 120
30
+
31
+ BRANCH_TARGET = 25
32
+ BRANCH_MIN = 12
33
+ AGE_WIDTH = 5
34
+ COMMIT_TARGET = 24
35
+ COMMIT_MIN = 10
36
+ CHANGES_WIDTH = 7
37
+ PR_WIDTH = 11
38
+ TASK_MIN = 4
39
+ PATH_INDENT = " "
40
+
41
+ # @param width [Integer] detected terminal width
42
+ # @param full [Boolean] exhaustive listing (absolute paths)
43
+ # @param env [Hash] environment (HOME shortening, COLUMNS detection)
44
+ def initialize(width: nil, full: false, env: ENV, stdout: $stdout)
45
+ @width = width || self.class.detected_width(env: env, stdout: stdout)
46
+ @full = full
47
+ @env = env
48
+ end
49
+
50
+ # Detect the output width.
51
+ #
52
+ # @param env [Hash]
53
+ # @param stdout [IO]
54
+ # @return [Integer]
55
+ def self.detected_width(env: ENV, stdout: $stdout)
56
+ columns = env["COLUMNS"].to_s.strip
57
+ if columns.match?(/\A\d+\z/)
58
+ value = Integer(columns, 10)
59
+ return value if value >= COLUMNS_MIN_WIDTH
60
+ end
61
+
62
+ if stdout.respond_to?(:tty?) && stdout.tty?
63
+ winsize = IO.console&.winsize
64
+ return winsize.last if winsize&.last
65
+ end
66
+
67
+ DEFAULT_WIDTH
68
+ rescue StandardError
69
+ DEFAULT_WIDTH
70
+ end
71
+
72
+ # Render header + separator + three lines per worktree (columns,
73
+ # indented full path, blank separator).
74
+ #
75
+ # @param worktrees [Array<Models::WorktreeInfo>]
76
+ # @return [Array<String>] output lines
77
+ def render(worktrees)
78
+ rows = Array(worktrees)
79
+ layout = compute_layout(rows)
80
+ [
81
+ header_line(layout),
82
+ separator_line(layout),
83
+ *rows.flat_map { |worktree| [row_line(worktree, layout), path_line(worktree, layout), ""] }
84
+ ]
85
+ end
86
+
87
+ private
88
+
89
+ Layout = Struct.new(:task, :branch, :age, :commit, :changes, :pr, :total_width,
90
+ keyword_init: true)
91
+
92
+ def compute_layout(worktrees)
93
+ task_width = [TASK_MIN, *worktrees.map { |wt| task_cell(wt).length }].max
94
+ branch_width = BRANCH_TARGET
95
+ commit_width = COMMIT_TARGET
96
+
97
+ unless @full
98
+ while total_width(task_width, branch_width, commit_width) > @width
99
+ if commit_width > COMMIT_MIN
100
+ commit_width -= 1
101
+ elsif branch_width > BRANCH_MIN
102
+ branch_width -= 1
103
+ else
104
+ break
105
+ end
106
+ end
107
+ end
108
+
109
+ layout = build_layout(task_width, branch_width, commit_width)
110
+ return layout if layout[:total_width] <= @width
111
+
112
+ drop_commit(layout)
113
+ end
114
+
115
+ def build_layout(task_width, branch_width, commit_width)
116
+ total = total_width(task_width, branch_width, commit_width)
117
+ Layout.new(task: task_width, branch: branch_width, age: AGE_WIDTH, commit: commit_width,
118
+ changes: CHANGES_WIDTH, pr: PR_WIDTH, total_width: total)
119
+ end
120
+
121
+ def total_width(task_width, branch_width, commit_width)
122
+ task_width + branch_width + AGE_WIDTH + commit_width + CHANGES_WIDTH + PR_WIDTH +
123
+ (GUTTER.length * 5)
124
+ end
125
+
126
+ # Drop the Commit column when even the minimum layout overflows.
127
+ def drop_commit(layout)
128
+ Layout.new(
129
+ task: layout[:task], branch: layout[:branch], age: layout[:age],
130
+ commit: 0, changes: layout[:changes], pr: layout[:pr],
131
+ total_width: layout[:total_width] - layout[:commit] - GUTTER.length
132
+ )
133
+ end
134
+
135
+ def header_line(layout)
136
+ cells = []
137
+ cells << pad("Task", layout[:task], :left)
138
+ cells << pad("Branch", layout[:branch], :left)
139
+ cells << pad("Age", layout[:age], :right)
140
+ cells << pad("Commit", layout[:commit], :left) if layout[:commit].positive?
141
+ cells << pad("Changes", layout[:changes], :right)
142
+ cells << pad("PR", layout[:pr], :left)
143
+ cells.join(GUTTER).rstrip
144
+ end
145
+
146
+ def separator_line(layout)
147
+ "-" * layout[:total_width]
148
+ end
149
+
150
+ def row_line(worktree, layout)
151
+ cells = []
152
+ cells << pad(task_cell(worktree), layout[:task], :left)
153
+ cells << pad(branch_cell(worktree, layout[:branch]), layout[:branch], :left)
154
+ cells << pad(age_cell(worktree), layout[:age], :right)
155
+ cells << pad(commit_cell(worktree, layout[:commit]), layout[:commit], :left) if layout[:commit].positive?
156
+ cells << pad(changes_cell(worktree), layout[:changes], :right)
157
+ cells << pad(pr_cell(worktree), layout[:pr], :left)
158
+ cells.join(GUTTER).rstrip
159
+ end
160
+
161
+ # The full, never-truncated worktree path on its own line, indented
162
+ # to the Commit column so it reads as a continuation of the row
163
+ # (falls back to the Branch column, then a minimal indent).
164
+ def path_line(worktree, layout)
165
+ path = @full ? full_path(worktree) : compact_path(worktree)
166
+ "#{path_indent(layout)}#{path}"
167
+ end
168
+
169
+ def path_indent(layout)
170
+ if layout[:commit].positive?
171
+ " " * (layout[:task] + GUTTER.length + layout[:branch] + GUTTER.length +
172
+ layout[:age] + GUTTER.length)
173
+ elsif layout[:branch].positive?
174
+ " " * (layout[:task] + GUTTER.length)
175
+ else
176
+ PATH_INDENT
177
+ end
178
+ end
179
+
180
+ # --- Cell contents ---
181
+
182
+ def task_cell(worktree)
183
+ return "-" if worktree.task_id.to_s.empty?
184
+
185
+ extra = worktree.task_ids.length - 1
186
+ extra.positive? ? "#{worktree.task_id}+#{extra}" : worktree.task_id
187
+ end
188
+
189
+ def branch_cell(worktree, width)
190
+ label = worktree.branch.to_s.empty? ? "detached" : worktree.branch
191
+ Atoms::TextTruncator.middle(label, width)
192
+ end
193
+
194
+ def age_cell(worktree)
195
+ return "?" if worktree.probe_failed?
196
+ return "never" if worktree.last_commit.nil?
197
+
198
+ Atoms::RelativeAgeFormatter.format(worktree.last_commit[:age_hours].to_f * 3600)
199
+ end
200
+
201
+ def commit_cell(worktree, width)
202
+ return "?" if worktree.probe_failed?
203
+ return "" if worktree.last_commit.nil?
204
+
205
+ Atoms::TextTruncator.right(worktree.last_commit[:subject].to_s, width)
206
+ end
207
+
208
+ def changes_cell(worktree)
209
+ return "?" if worktree.probe_failed?
210
+ return "-" if worktree.uncommitted.nil?
211
+
212
+ worktree.uncommitted[:total].to_i.to_s
213
+ end
214
+
215
+ def pr_cell(worktree)
216
+ PrStatusResolver.cell(worktree.pr)
217
+ end
218
+
219
+ def full_path(worktree)
220
+ File.expand_path(worktree.path)
221
+ end
222
+
223
+ def compact_path(worktree)
224
+ path = worktree.path.to_s
225
+ home = @env["HOME"].to_s
226
+ path = "~#{path[home.length..]}" if !home.empty? && path.start_with?(home)
227
+ path
228
+ end
229
+
230
+ def pad(value, width, alignment)
231
+ text = value.to_s
232
+ text = text[0, width].to_s if text.length > width
233
+ return text.ljust(width) if alignment == :left
234
+
235
+ text.rjust(width)
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,315 @@
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
+ supplement_untracked_task_files(raw_worktrees, probes, project_root, task_index)
69
+ enriched = enrich(raw_worktrees, probes, task_index, now, options)
70
+ selected = apply_filters(enriched, options)
71
+ active, inactive = selected.partition { |worktree| worktree.active_within_window }
72
+
73
+ statistics = build_statistics(selected, active, inactive, now, options, raw_worktrees)
74
+ output = render(selected, active, inactive, statistics, options)
75
+
76
+ {
77
+ success: true,
78
+ output: output,
79
+ worktrees: selected,
80
+ statistics: statistics
81
+ }
82
+ end
83
+
84
+ private
85
+
86
+ def normalize_options(options)
87
+ window = options[:active_within]
88
+ window = Atoms::DurationParser.parse(window) if window.is_a?(String)
89
+ window ||= Atoms::DurationParser.default
90
+ options.merge(
91
+ format: (options[:format] || :table).to_sym,
92
+ window_hours: window[:hours],
93
+ window_label: window[:label],
94
+ full: options[:full] ? true : false,
95
+ quiet: options[:quiet] ? true : false
96
+ )
97
+ end
98
+
99
+ def build_task_index
100
+ if @task_index_builder
101
+ @task_index_builder.build
102
+ else
103
+ Molecules::TaskIndexBuilder.new(project_root: @project_root).build
104
+ end
105
+ end
106
+
107
+ def probe_worktrees(raw_worktrees, project_root)
108
+ runner = @probe_runner || WorktreeProbeRunner.new(
109
+ project_root: project_root,
110
+ base_ref: raw_worktrees.first&.commit
111
+ )
112
+ runner.run(raw_worktrees)
113
+ end
114
+
115
+ # Second probe round, only for worktrees the cheap association tiers
116
+ # (metadata, `_current` symlink, validated name, tracked task-file
117
+ # changes, committed task files) could not resolve. The deep
118
+ # `.ace-tasks/` untracked scan dominates listing time on large
119
+ # repositories, so it runs selectively and merges its findings back
120
+ # into the round-one payloads (counts + changed task paths) before
121
+ # enrichment classifies activity and renders.
122
+ def supplement_untracked_task_files(raw_worktrees, probes, project_root, task_index)
123
+ return unless task_index.available?
124
+
125
+ resolver = Molecules::TaskAssociationResolver.new(task_index: task_index)
126
+ unresolved = raw_worktrees.each_index.select do |index|
127
+ probe = probes[index]
128
+ probe && resolver.resolve(raw_worktrees[index], probe).task_id.nil?
129
+ end
130
+ return if unresolved.empty?
131
+
132
+ probe_molecule = Molecules::WorktreeProbe.new(project_root: project_root)
133
+ supplement = ->(worktree) { {untracked_task_files: probe_molecule.untracked_task_files(worktree.path)} }
134
+ runner = WorktreeProbeRunner.new(project_root: project_root, probe: supplement)
135
+ payloads = runner.run(unresolved.map { |index| raw_worktrees[index] })
136
+
137
+ unresolved.each_with_index do |probe_index, position|
138
+ payload = payloads[position]
139
+ next if payload.nil? || payload[:untracked_task_files].to_a.empty?
140
+
141
+ paths = payload[:untracked_task_files]
142
+ probe = probes[probe_index]
143
+ probe[:untracked] = probe[:untracked].to_i + paths.length
144
+ probe[:uncommitted_paths] = Array(probe[:uncommitted_paths]) + paths
145
+ probe[:changed_task_files] = Array(probe[:changed_task_files]) + paths
146
+ end
147
+ end
148
+
149
+ def enrich(raw_worktrees, probes, task_index, now, options)
150
+ resolver = Molecules::TaskAssociationResolver.new(task_index: task_index)
151
+ pr_resolver = @pr_resolver || Molecules::PrStatusResolver.new
152
+
153
+ raw_worktrees.each_with_index.map do |worktree, index|
154
+ probe = probes[index]
155
+ associated = resolver.resolve(worktree, probe)
156
+ last_commit = last_commit_hash(probe, now)
157
+ uncommitted = uncommitted_hash(probe)
158
+ active = active_worktree?(worktree, probe, options[:window_hours], now)
159
+ pr = pr_resolver.resolve(associated, associated.task_id)
160
+
161
+ associated.with_enrichment(
162
+ last_commit: last_commit,
163
+ uncommitted: uncommitted,
164
+ active_within_window: active,
165
+ pr: pr,
166
+ probe_error: probe.nil?
167
+ )
168
+ end
169
+ end
170
+
171
+ def last_commit_hash(probe, now)
172
+ return nil if probe.nil? || probe[:head_sha].nil?
173
+
174
+ committed_at = Time.at(probe[:head_committer_epoch].to_i).utc
175
+ {
176
+ sha: probe[:head_sha],
177
+ age_hours: ((now - committed_at) / 3600.0).round(2),
178
+ committed_at: committed_at.iso8601,
179
+ subject: probe[:head_subject].to_s
180
+ }
181
+ end
182
+
183
+ def uncommitted_hash(probe)
184
+ return nil if probe.nil?
185
+
186
+ staged = probe[:staged].to_i
187
+ unstaged = probe[:unstaged].to_i
188
+ untracked = probe[:untracked].to_i
189
+ total = staged + unstaged + untracked
190
+ return nil unless total.positive?
191
+
192
+ {staged: staged, unstaged: unstaged, untracked: untracked, total: total}
193
+ end
194
+
195
+ # A worktree is active inside the window when its HEAD commit is in
196
+ # the window or it has uncommitted changes. Bare worktrees are never
197
+ # active; probe failures classify as inactive.
198
+ def active_worktree?(worktree, probe, window_hours, now)
199
+ return false if worktree.bare || probe.nil?
200
+
201
+ committed_at = probe[:head_committer_epoch]
202
+ recent_commit = committed_at && now.to_i - committed_at.to_i <= (window_hours * 3600).to_i
203
+ dirty = uncommitted_hash(probe)
204
+ recent_commit || !dirty.nil?
205
+ end
206
+
207
+ def apply_filters(worktrees, options)
208
+ filtered = Array(worktrees)
209
+
210
+ if options[:task_associated] == true
211
+ filtered = filtered.select { |worktree| worktree.task_associated? }
212
+ elsif options[:task_associated] == false
213
+ filtered = filtered.reject { |worktree| worktree.task_associated? }
214
+ end
215
+
216
+ if options[:usable] == true
217
+ filtered = filtered.select(&:usable?)
218
+ elsif options[:usable] == false
219
+ filtered = filtered.reject(&:usable?)
220
+ end
221
+
222
+ if options[:search] && !options[:search].to_s.empty?
223
+ pattern = Regexp.new(options[:search].to_s, Regexp::IGNORECASE)
224
+ filtered = filtered.select { |worktree| worktree.branch&.match?(pattern) }
225
+ end
226
+
227
+ filtered
228
+ end
229
+
230
+ def build_statistics(selected, active, inactive, now, options, raw_worktrees)
231
+ {
232
+ total: selected.length,
233
+ active: active.length,
234
+ inactive: inactive.length,
235
+ task_associated: selected.count(&:task_associated?),
236
+ usable: selected.count(&:usable?),
237
+ recent_branches: recent_branch_count(raw_worktrees, now, options),
238
+ window_hours: options[:window_hours],
239
+ window_label: options[:window_label]
240
+ }
241
+ end
242
+
243
+ # Branch-based count; worktree filters never change it, so checkout
244
+ # exclusions use the complete inventory.
245
+ def recent_branch_count(raw_worktrees, now, options)
246
+ if @branch_counter
247
+ @branch_counter.call(worktrees: raw_worktrees, window_hours: options[:window_hours], now: now)
248
+ else
249
+ Molecules::RecentBranchCounter.new(
250
+ worktrees: raw_worktrees, window_hours: options[:window_hours], now: now
251
+ ).count
252
+ end
253
+ end
254
+
255
+ # --- Rendering ---
256
+
257
+ def render(selected, active, inactive, statistics, options)
258
+ case options[:format]
259
+ when :json
260
+ render_json(selected)
261
+ when :simple
262
+ render_simple(selected)
263
+ else
264
+ render_table(active, inactive, statistics, options)
265
+ end
266
+ end
267
+
268
+ def render_json(worktrees)
269
+ JSON.generate(worktrees.map(&:to_h))
270
+ end
271
+
272
+ def render_simple(worktrees)
273
+ @lister.format_for_display(worktrees, :simple)
274
+ end
275
+
276
+ def render_table(active, inactive, statistics, options)
277
+ footer = Molecules::WorktreeFooterFormatter.new(
278
+ window_label: statistics[:window_label],
279
+ full: options[:full]
280
+ )
281
+ table = Molecules::WorktreeTableFormatter.new(env: @env, full: options[:full], stdout: @stdout)
282
+
283
+ lines = [footer.header(active_count: active.length, total_count: statistics[:total])]
284
+
285
+ if statistics[:total].zero?
286
+ return "No worktrees found."
287
+ end
288
+
289
+ rows = options[:full] ? (active + inactive) : active
290
+ table_ended_with_blank = false
291
+ if rows.empty?
292
+ lines << footer.no_active_message
293
+ else
294
+ lines.concat(table.render(rows))
295
+ table_ended_with_blank = true
296
+ end
297
+
298
+ unless options[:quiet]
299
+ # The table already ends each entry with a blank separator line.
300
+ lines << "" unless table_ended_with_blank
301
+ lines.concat(footer.lines(
302
+ inactive: inactive.length,
303
+ task_associated: statistics[:task_associated],
304
+ usable: statistics[:usable],
305
+ recent_branches: statistics[:recent_branches]
306
+ ))
307
+ end
308
+
309
+ lines.join("\n")
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
315
+ 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