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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/docs/usage.md +37 -3
  4. data/handbook/skills/as-git-worktree-cleanup/SKILL.md +20 -0
  5. data/handbook/workflow-instructions/git/worktree-cleanup.wf.md +63 -0
  6. data/handbook/workflow-instructions/git/worktree-create.wf.md +6 -0
  7. data/lib/ace/git/worktree/cli/commands/bootstrap.rb +43 -0
  8. data/lib/ace/git/worktree/cli/commands/cleanup.rb +45 -0
  9. data/lib/ace/git/worktree/cli/commands/config.rb +27 -4
  10. data/lib/ace/git/worktree/cli/commands/create.rb +1 -0
  11. data/lib/ace/git/worktree/cli.rb +6 -0
  12. data/lib/ace/git/worktree/commands/bootstrap_command.rb +159 -0
  13. data/lib/ace/git/worktree/commands/cleanup_command.rb +307 -0
  14. data/lib/ace/git/worktree/commands/config_command.rb +354 -181
  15. data/lib/ace/git/worktree/commands/create_command.rb +5 -0
  16. data/lib/ace/git/worktree/models/worktree_config.rb +33 -3
  17. data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +99 -0
  18. data/lib/ace/git/worktree/molecules/cleanup_applier.rb +225 -0
  19. data/lib/ace/git/worktree/molecules/cleanup_pr_resolver.rb +213 -0
  20. data/lib/ace/git/worktree/molecules/cleanup_reporter.rb +396 -0
  21. data/lib/ace/git/worktree/molecules/config_loader.rb +1 -1
  22. data/lib/ace/git/worktree/molecules/task_committer.rb +55 -8
  23. data/lib/ace/git/worktree/molecules/toolchain_truster.rb +112 -0
  24. data/lib/ace/git/worktree/molecules/worktree_lister.rb +17 -1
  25. data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +159 -10
  26. data/lib/ace/git/worktree/organisms/worktree_manager.rb +4 -2
  27. data/lib/ace/git/worktree/version.rb +1 -1
  28. 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