ace-git-worktree 0.21.8 โ 0.22.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/CHANGELOG.md +12 -0
- data/docs/usage.md +37 -3
- data/handbook/skills/as-git-worktree-cleanup/SKILL.md +20 -0
- data/handbook/workflow-instructions/git/worktree-cleanup.wf.md +63 -0
- data/handbook/workflow-instructions/git/worktree-create.wf.md +6 -0
- data/lib/ace/git/worktree/cli/commands/bootstrap.rb +43 -0
- data/lib/ace/git/worktree/cli/commands/cleanup.rb +45 -0
- data/lib/ace/git/worktree/cli/commands/config.rb +27 -4
- data/lib/ace/git/worktree/cli/commands/create.rb +1 -0
- data/lib/ace/git/worktree/cli.rb +6 -0
- data/lib/ace/git/worktree/commands/bootstrap_command.rb +159 -0
- data/lib/ace/git/worktree/commands/cleanup_command.rb +307 -0
- data/lib/ace/git/worktree/commands/config_command.rb +354 -181
- data/lib/ace/git/worktree/commands/create_command.rb +5 -0
- data/lib/ace/git/worktree/models/worktree_config.rb +33 -3
- data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +99 -0
- data/lib/ace/git/worktree/molecules/cleanup_applier.rb +225 -0
- data/lib/ace/git/worktree/molecules/cleanup_pr_resolver.rb +213 -0
- data/lib/ace/git/worktree/molecules/cleanup_reporter.rb +396 -0
- data/lib/ace/git/worktree/molecules/config_loader.rb +1 -1
- data/lib/ace/git/worktree/molecules/task_committer.rb +55 -8
- data/lib/ace/git/worktree/molecules/toolchain_truster.rb +112 -0
- data/lib/ace/git/worktree/molecules/worktree_lister.rb +17 -1
- data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +159 -10
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +4 -2
- data/lib/ace/git/worktree/version.rb +1 -1
- metadata +17 -6
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../molecules/cleanup_reporter"
|
|
5
|
+
require_relative "../molecules/cleanup_applier"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
module Worktree
|
|
10
|
+
module Commands
|
|
11
|
+
# Cleanup command
|
|
12
|
+
#
|
|
13
|
+
# Generates a complete, deterministic, report-only worktree cleanup
|
|
14
|
+
# inventory using ancestry as the first safe proof. No mutations are
|
|
15
|
+
# performed โ this is strictly a report.
|
|
16
|
+
#
|
|
17
|
+
# @example Generate a cleanup report
|
|
18
|
+
# CleanupCommand.new.run(["--target", "main", "--remote", "origin"])
|
|
19
|
+
#
|
|
20
|
+
# @example JSON output for automation
|
|
21
|
+
# CleanupCommand.new.run(["--target", "main", "--format", "json"])
|
|
22
|
+
class CleanupCommand
|
|
23
|
+
# Run the cleanup command
|
|
24
|
+
#
|
|
25
|
+
# @param args [Array<String>] Command arguments
|
|
26
|
+
# @return [Integer] Exit code (0 for success, 1 for error)
|
|
27
|
+
def run(args = [])
|
|
28
|
+
options = parse_arguments(args)
|
|
29
|
+
return show_help if options[:help]
|
|
30
|
+
|
|
31
|
+
validate_options(options)
|
|
32
|
+
|
|
33
|
+
reporter = Molecules::CleanupReporter.new(
|
|
34
|
+
target: options[:target],
|
|
35
|
+
remote: options[:remote],
|
|
36
|
+
offline: options[:offline]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
result = reporter.report
|
|
40
|
+
|
|
41
|
+
if result[:success]
|
|
42
|
+
if options[:apply]
|
|
43
|
+
applier = Molecules::CleanupApplier.new(result, options[:approved_digest])
|
|
44
|
+
apply_result = applier.apply
|
|
45
|
+
|
|
46
|
+
if apply_result[:success]
|
|
47
|
+
# Strict rescan
|
|
48
|
+
final_reporter = Molecules::CleanupReporter.new(
|
|
49
|
+
target: options[:target],
|
|
50
|
+
remote: options[:remote],
|
|
51
|
+
offline: options[:offline]
|
|
52
|
+
)
|
|
53
|
+
final_result = final_reporter.report
|
|
54
|
+
|
|
55
|
+
if final_result[:success]
|
|
56
|
+
# Check require_only_target if needed
|
|
57
|
+
if options[:require_only_target] && has_non_target_state?(final_result)
|
|
58
|
+
puts "Cleanup applied successfully, but non-target state remains (failed strict --require-only-target check)."
|
|
59
|
+
display_result(final_result, options)
|
|
60
|
+
return 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
display_apply_result(apply_result, final_result, options)
|
|
64
|
+
return 0
|
|
65
|
+
else
|
|
66
|
+
puts "Cleanup applied successfully, but final strict rescan failed: #{final_result[:error]}"
|
|
67
|
+
return 1
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
puts "Error applying cleanup: #{apply_result[:error]}"
|
|
71
|
+
display_apply_result(apply_result, nil, options)
|
|
72
|
+
return 1
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
display_result(result, options)
|
|
76
|
+
return 0
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
if options[:format] == "json"
|
|
80
|
+
puts JSON.pretty_generate(result)
|
|
81
|
+
else
|
|
82
|
+
puts "Error: #{result[:error]}"
|
|
83
|
+
end
|
|
84
|
+
1
|
|
85
|
+
end
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
puts "Error: #{e.message}"
|
|
88
|
+
puts
|
|
89
|
+
show_help
|
|
90
|
+
1
|
|
91
|
+
rescue => e
|
|
92
|
+
puts "Error: #{e.message}"
|
|
93
|
+
1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Show help
|
|
97
|
+
#
|
|
98
|
+
# @return [Integer] Exit code
|
|
99
|
+
def show_help
|
|
100
|
+
puts <<~HELP
|
|
101
|
+
USAGE
|
|
102
|
+
ace-git-worktree cleanup --target <ref> --remote <name> [OPTIONS]
|
|
103
|
+
|
|
104
|
+
OPTIONS
|
|
105
|
+
--target <ref> Target ref for ancestry proof (required)
|
|
106
|
+
--remote <name> Remote name (default: origin)
|
|
107
|
+
--offline Skip remote evidence refresh
|
|
108
|
+
--apply Apply a reviewed plan
|
|
109
|
+
--approved-digest <sha256> Approved plan digest to apply
|
|
110
|
+
--require-only-target Fail if final rescan has retained non-target state
|
|
111
|
+
--format <type> Output format: table, json (default: table)
|
|
112
|
+
--help Show this help
|
|
113
|
+
|
|
114
|
+
DESCRIPTION
|
|
115
|
+
Generates a complete cleanup inventory without performing any
|
|
116
|
+
mutations. Each worktree, local ref, and remote ref is classified
|
|
117
|
+
by ancestry proof and retention policy.
|
|
118
|
+
|
|
119
|
+
The report includes a canonical SHA-256 plan digest that can be
|
|
120
|
+
used with --apply (in a future version) to safely execute the
|
|
121
|
+
reviewed plan.
|
|
122
|
+
|
|
123
|
+
EXAMPLES
|
|
124
|
+
ace-git-worktree cleanup --target main
|
|
125
|
+
ace-git-worktree cleanup --target main --format json
|
|
126
|
+
ace-git-worktree cleanup --target main --offline
|
|
127
|
+
HELP
|
|
128
|
+
0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def parse_arguments(args)
|
|
134
|
+
options = {
|
|
135
|
+
target: nil, remote: "origin", offline: false, format: "table", help: false,
|
|
136
|
+
apply: false, approved_digest: nil, require_only_target: false
|
|
137
|
+
}
|
|
138
|
+
i = 0
|
|
139
|
+
while i < args.length
|
|
140
|
+
case args[i]
|
|
141
|
+
when "--target"
|
|
142
|
+
i += 1
|
|
143
|
+
options[:target] = args[i]
|
|
144
|
+
when "--remote"
|
|
145
|
+
i += 1
|
|
146
|
+
options[:remote] = args[i]
|
|
147
|
+
when "--offline"
|
|
148
|
+
options[:offline] = true
|
|
149
|
+
when "--apply"
|
|
150
|
+
options[:apply] = true
|
|
151
|
+
when "--approved-digest"
|
|
152
|
+
i += 1
|
|
153
|
+
options[:approved_digest] = args[i]
|
|
154
|
+
when "--require-only-target"
|
|
155
|
+
options[:require_only_target] = true
|
|
156
|
+
when "--format"
|
|
157
|
+
i += 1
|
|
158
|
+
options[:format] = args[i]
|
|
159
|
+
when "--help", "-h"
|
|
160
|
+
options[:help] = true
|
|
161
|
+
else
|
|
162
|
+
raise ArgumentError, "Unknown option: #{args[i]}"
|
|
163
|
+
end
|
|
164
|
+
i += 1
|
|
165
|
+
end
|
|
166
|
+
options
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_options(options)
|
|
170
|
+
raise ArgumentError, "--target is required" unless options[:target]
|
|
171
|
+
raise ArgumentError, "--remote is required" unless options[:remote]
|
|
172
|
+
|
|
173
|
+
valid_formats = %w[table json]
|
|
174
|
+
unless valid_formats.include?(options[:format])
|
|
175
|
+
raise ArgumentError, "Invalid format '#{options[:format]}'. Use: #{valid_formats.join(", ")}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if options[:apply] && !options[:approved_digest]
|
|
179
|
+
raise ArgumentError, "--approved-digest is required when --apply is used"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if options[:approved_digest] && !options[:apply]
|
|
183
|
+
raise ArgumentError, "--apply is required when --approved-digest is used"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def display_result(result, options)
|
|
188
|
+
if options[:format] == "json"
|
|
189
|
+
puts JSON.pretty_generate(result)
|
|
190
|
+
else
|
|
191
|
+
display_terminal(result)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def display_terminal(result)
|
|
196
|
+
puts ""
|
|
197
|
+
puts "๐งน Worktree Cleanup Report"
|
|
198
|
+
puts "=" * 50
|
|
199
|
+
puts " Target: #{result[:target][:ref]} (#{result[:target][:sha][0..7]})"
|
|
200
|
+
puts " Remote: #{result[:remote][:name]} (#{result[:remote][:sha]&.slice(0, 8) || "n/a"})"
|
|
201
|
+
puts " Mode: #{result[:refresh][:status]}"
|
|
202
|
+
puts ""
|
|
203
|
+
|
|
204
|
+
# Worktrees
|
|
205
|
+
puts "Worktrees (#{result[:worktrees].length}):"
|
|
206
|
+
puts "-" * 40
|
|
207
|
+
result[:worktrees].each do |wt|
|
|
208
|
+
glyph = wt[:action] == "remove" ? "โ" : "โ"
|
|
209
|
+
status = wt[:primary] ? " [primary]" : ""
|
|
210
|
+
status += " [locked]" if wt[:locked]
|
|
211
|
+
status += " [dirty]" if wt[:dirty] && !wt[:dirty].values.all?(&:empty?)
|
|
212
|
+
puts " #{glyph} #{wt[:path]}#{status}"
|
|
213
|
+
puts " branch: #{wt[:branch] || "(detached)"}, sha: #{wt[:sha]&.slice(0, 8)}"
|
|
214
|
+
puts " ancestry: #{wt[:ancestry] || "n/a"}, action: #{wt[:action]}"
|
|
215
|
+
puts " reason: #{wt[:retention_reason]}" if wt[:retention_reason]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Local refs
|
|
219
|
+
puts ""
|
|
220
|
+
puts "Local Refs (#{result[:local_refs].length}):"
|
|
221
|
+
puts "-" * 40
|
|
222
|
+
result[:local_refs].each do |ref|
|
|
223
|
+
glyph = ref[:action] == "remove" ? "โ" : "โ"
|
|
224
|
+
prot = ref[:protected] ? " [protected]" : ""
|
|
225
|
+
puts " #{glyph} #{ref[:name]}#{prot} (#{ref[:sha]&.slice(0, 8)})"
|
|
226
|
+
puts " ancestry: #{ref[:ancestry] || "n/a"}, action: #{ref[:action]}"
|
|
227
|
+
puts " reason: #{ref[:retention_reason]}" if ref[:retention_reason]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Remote refs
|
|
231
|
+
puts ""
|
|
232
|
+
puts "Remote Refs (#{result[:remote_refs].length}):"
|
|
233
|
+
puts "-" * 40
|
|
234
|
+
result[:remote_refs].each do |ref|
|
|
235
|
+
glyph = ref[:action] == "remove" ? "โ" : "โ"
|
|
236
|
+
prot = ref[:protected] ? " [protected]" : ""
|
|
237
|
+
puts " #{glyph} #{ref[:name]}#{prot} (#{ref[:sha]&.slice(0, 8)})"
|
|
238
|
+
puts " ancestry: #{ref[:ancestry] || "n/a"}, action: #{ref[:action]}"
|
|
239
|
+
puts " reason: #{ref[:retention_reason]}" if ref[:retention_reason]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Action plan
|
|
243
|
+
removable = result[:actions]
|
|
244
|
+
puts ""
|
|
245
|
+
puts "Proposed Actions (#{removable.length}):"
|
|
246
|
+
puts "-" * 40
|
|
247
|
+
if removable.empty?
|
|
248
|
+
puts " No removable items found."
|
|
249
|
+
else
|
|
250
|
+
removable.each_with_index do |action, idx|
|
|
251
|
+
puts " #{idx + 1}. #{action[:type]}: #{action[:target]} (#{action[:sha]&.slice(0, 8)})"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Digest
|
|
256
|
+
puts ""
|
|
257
|
+
puts "Plan Digest: #{result[:plan_digest]}"
|
|
258
|
+
puts "=" * 50
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def has_non_target_state?(final_result)
|
|
262
|
+
# A strict rescan ensures no retained worktrees/refs except target
|
|
263
|
+
# Worktrees: only primary should remain.
|
|
264
|
+
worktrees_ok = final_result[:worktrees].all? { |wt| wt[:primary] }
|
|
265
|
+
|
|
266
|
+
# Local refs: only the target should remain.
|
|
267
|
+
local_refs_ok = final_result[:local_refs].all? { |r| r[:name] == final_result[:target][:ref] }
|
|
268
|
+
|
|
269
|
+
# Remote refs: only the target should remain.
|
|
270
|
+
remote_refs_ok = final_result[:remote_refs].all? { |r| r[:short_name] == final_result[:target][:ref] }
|
|
271
|
+
|
|
272
|
+
!(worktrees_ok && local_refs_ok && remote_refs_ok)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def display_apply_result(apply_result, final_result, options)
|
|
276
|
+
if options[:format] == "json"
|
|
277
|
+
out = { apply: apply_result }
|
|
278
|
+
out[:rescan] = final_result if final_result
|
|
279
|
+
puts JSON.pretty_generate(out)
|
|
280
|
+
return
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
puts ""
|
|
284
|
+
puts "๐งน Cleanup Apply Results"
|
|
285
|
+
puts "=" * 50
|
|
286
|
+
|
|
287
|
+
if apply_result[:ledger].empty?
|
|
288
|
+
puts " No actions were required."
|
|
289
|
+
else
|
|
290
|
+
apply_result[:ledger].each_with_index do |entry, i|
|
|
291
|
+
glyph = entry[:success] ? "โ" : "โ"
|
|
292
|
+
puts " #{glyph} #{entry[:action][:type]}: #{entry[:action][:target]}"
|
|
293
|
+
puts " Error: #{entry[:error]}" if entry[:error]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if final_result
|
|
298
|
+
puts ""
|
|
299
|
+
puts "Strict Rescan Digest: #{final_result[:plan_digest]}"
|
|
300
|
+
end
|
|
301
|
+
puts "=" * 50
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|