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.
- checksums.yaml +4 -4
- data/.ace-defaults/nav/protocols/skill-sources/ace-git-worktree.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +0 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE +0 -0
- data/README.md +0 -0
- data/Rakefile +0 -0
- data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
- data/docs/demo/ace-git-worktree-getting-started.tape.yml +0 -0
- data/docs/demo/fixtures/README.md +0 -0
- data/docs/demo/fixtures/sample.txt +0 -0
- data/docs/getting-started.md +7 -4
- data/docs/handbook.md +0 -0
- data/docs/usage.md +153 -24
- data/handbook/agents/worktree.ag.md +10 -6
- data/handbook/skills/as-git-worktree/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-cleanup/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-create/SKILL.md +0 -0
- data/handbook/skills/as-git-worktree-manage/SKILL.md +0 -0
- 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/git_command.rb +0 -0
- data/lib/ace/git/worktree/atoms/path_expander.rb +0 -0
- data/lib/ace/git/worktree/atoms/relative_age_formatter.rb +59 -0
- data/lib/ace/git/worktree/atoms/slug_generator.rb +0 -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/bootstrap.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/cleanup.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/config.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/create.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/list.rb +29 -8
- data/lib/ace/git/worktree/cli/commands/prune.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/remove.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +0 -0
- data/lib/ace/git/worktree/cli/commands/switch.rb +0 -0
- data/lib/ace/git/worktree/cli.rb +1 -1
- data/lib/ace/git/worktree/commands/bootstrap_command.rb +0 -0
- data/lib/ace/git/worktree/commands/list_command.rb +90 -94
- data/lib/ace/git/worktree/commands/prune_command.rb +0 -0
- data/lib/ace/git/worktree/commands/remove_command.rb +0 -0
- data/lib/ace/git/worktree/commands/switch_command.rb +0 -0
- data/lib/ace/git/worktree/configuration.rb +0 -0
- data/lib/ace/git/worktree/models/task_index.rb +186 -0
- data/lib/ace/git/worktree/models/worktree_info.rb +52 -3
- data/lib/ace/git/worktree/models/worktree_metadata.rb +0 -0
- data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +0 -0
- data/lib/ace/git/worktree/molecules/cleanup_applier.rb +0 -0
- data/lib/ace/git/worktree/molecules/config_loader.rb +0 -0
- data/lib/ace/git/worktree/molecules/current_task_linker.rb +0 -0
- data/lib/ace/git/worktree/molecules/hook_executor.rb +0 -0
- data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +0 -0
- 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_fetcher.rb +0 -0
- data/lib/ace/git/worktree/molecules/task_index_builder.rb +121 -0
- data/lib/ace/git/worktree/molecules/task_pusher.rb +0 -0
- data/lib/ace/git/worktree/molecules/task_status_updater.rb +0 -0
- data/lib/ace/git/worktree/molecules/toolchain_truster.rb +0 -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 +196 -0
- data/lib/ace/git/worktree/molecules/worktree_remover.rb +0 -0
- data/lib/ace/git/worktree/molecules/worktree_table_formatter.rb +241 -0
- data/lib/ace/git/worktree/organisms/worktree_list_report.rb +315 -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,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
|
|
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
|