ace-git-worktree 0.21.6 → 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 +21 -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 +23 -12
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "open3"
6
+ require "time"
7
+ require_relative "cleanup_pr_resolver"
8
+
9
+ module Ace
10
+ module Git
11
+ module Worktree
12
+ module Molecules
13
+ # Builds a complete, deterministic, report-only worktree cleanup inventory.
14
+ #
15
+ # Inventories worktrees, local refs, and remote refs independently.
16
+ # Proves ancestry where possible. Produces an ordered no-mutation plan
17
+ # with a canonical SHA-256 digest.
18
+ class CleanupReporter
19
+ SCHEMA_VERSION = "1.0"
20
+
21
+ # @param target [String] Target ref (e.g. "main", "origin/main")
22
+ # @param remote [String] Remote name (e.g. "origin")
23
+ # @param offline [Boolean] Skip remote refresh
24
+ def initialize(target:, remote: "origin", offline: false)
25
+ @target = target
26
+ @remote = remote
27
+ @offline = offline
28
+ end
29
+
30
+ # Build complete cleanup report.
31
+ # @return [Hash] Report with inventories, actions, and plan_digest
32
+ def report
33
+ common_dir = resolve_common_dir
34
+ return error_result("Cannot resolve common git directory") unless common_dir
35
+
36
+ target_sha = resolve_ref(@target)
37
+ return error_result("Cannot resolve target ref '#{@target}'") unless target_sha
38
+
39
+ remote_sha = resolve_ref("#{@remote}/#{@target}")
40
+
41
+ # Refresh remote evidence (fetch objects only, no ref update)
42
+ refresh_result = refresh_remote_evidence unless @offline
43
+
44
+ # Collect inventories
45
+ worktrees = inventory_worktrees(common_dir)
46
+ local_refs = inventory_local_refs
47
+ remote_refs = inventory_remote_refs
48
+
49
+ pr_resolver = CleanupPrResolver.new(target: @target, target_sha: target_sha, offline: @offline)
50
+
51
+ # Classify each item
52
+ classify_worktrees(worktrees, target_sha, pr_resolver)
53
+ classify_refs(local_refs, target_sha, "local", pr_resolver)
54
+ classify_refs(remote_refs, target_sha, "remote", pr_resolver)
55
+
56
+ # Build ordered action plan
57
+ actions = build_action_plan(worktrees, local_refs, remote_refs)
58
+
59
+ # Compute canonical digest
60
+ plan_digest = compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha)
61
+
62
+ {
63
+ success: true,
64
+ schema_version: SCHEMA_VERSION,
65
+ repository: common_dir,
66
+ target: {ref: @target, sha: target_sha},
67
+ remote: {name: @remote, sha: remote_sha},
68
+ refresh: @offline ? {status: "offline"} : (refresh_result || {status: "skipped"}),
69
+ worktrees: worktrees,
70
+ local_refs: local_refs,
71
+ remote_refs: remote_refs,
72
+ actions: actions,
73
+ plan_digest: plan_digest
74
+ }
75
+ end
76
+
77
+ private
78
+
79
+ def error_result(message)
80
+ {success: false, error: message, schema_version: SCHEMA_VERSION}
81
+ end
82
+
83
+ # --- Repository resolution ---
84
+
85
+ def resolve_common_dir
86
+ out, status = Open3.capture2("git", "rev-parse", "--git-common-dir")
87
+ return nil unless status.success?
88
+
89
+ path = out.strip
90
+ # Normalize to absolute
91
+ File.expand_path(path)
92
+ end
93
+
94
+ def resolve_ref(ref)
95
+ out, status = Open3.capture2("git", "rev-parse", "--verify", "#{ref}^{commit}")
96
+ return nil unless status.success?
97
+
98
+ out.strip
99
+ end
100
+
101
+ # --- Remote evidence ---
102
+
103
+ def refresh_remote_evidence
104
+ # Fetch objects without updating refs
105
+ _out, _err, status = Open3.capture3(
106
+ "git", "fetch", @remote, "--no-tags", "--no-write-fetch-head"
107
+ )
108
+ {status: status.success? ? "refreshed" : "failed"}
109
+ rescue Errno::ENOENT
110
+ {status: "unavailable"}
111
+ end
112
+
113
+ # --- Worktree inventory ---
114
+
115
+ def inventory_worktrees(common_dir)
116
+ out, status = Open3.capture2("git", "worktree", "list", "--porcelain")
117
+ return [] unless status.success?
118
+
119
+ parse_porcelain_worktrees(out, common_dir)
120
+ end
121
+
122
+ def parse_porcelain_worktrees(output, common_dir)
123
+ blocks = output.split("\n\n").reject(&:empty?)
124
+ blocks.map { |block| parse_worktree_block(block, common_dir) }.compact
125
+ end
126
+
127
+ def parse_worktree_block(block, common_dir)
128
+ fields = {}
129
+ block.each_line do |line|
130
+ line = line.chomp
131
+ if line.start_with?("worktree ")
132
+ fields[:path] = line.sub("worktree ", "")
133
+ elsif line.start_with?("HEAD ")
134
+ fields[:sha] = line.sub("HEAD ", "")
135
+ elsif line.start_with?("branch ")
136
+ fields[:branch] = line.sub("branch ", "").sub("refs/heads/", "")
137
+ elsif line == "bare"
138
+ fields[:bare] = true
139
+ elsif line == "detached"
140
+ fields[:detached] = true
141
+ elsif line.start_with?("locked")
142
+ fields[:locked] = true
143
+ reason = line.sub("locked", "").strip
144
+ fields[:lock_reason] = reason unless reason.empty?
145
+ elsif line.start_with?("prunable")
146
+ fields[:prunable] = true
147
+ end
148
+ end
149
+
150
+ return nil unless fields[:path]
151
+
152
+ primary = primary_worktree?(fields[:path], common_dir)
153
+ dirty = primary ? nil : dirty_state(fields[:path])
154
+
155
+ {
156
+ path: fields[:path],
157
+ sha: fields[:sha],
158
+ branch: fields[:branch],
159
+ primary: primary,
160
+ bare: fields[:bare] || false,
161
+ detached: fields[:detached] || false,
162
+ locked: fields[:locked] || false,
163
+ lock_reason: fields[:lock_reason],
164
+ prunable: fields[:prunable] || false,
165
+ dirty: dirty,
166
+ protected: primary || (fields[:locked] || false),
167
+ ancestry: nil,
168
+ action: nil,
169
+ retention_reason: nil
170
+ }
171
+ end
172
+
173
+ def primary_worktree?(path, common_dir)
174
+ # Primary worktree contains the .git directory
175
+ git_path = File.join(path, ".git")
176
+ return true if File.directory?(git_path)
177
+
178
+ # Also check if common_dir parent matches
179
+ common_parent = File.dirname(common_dir)
180
+ File.expand_path(path) == File.expand_path(common_parent)
181
+ end
182
+
183
+ def dirty_state(path)
184
+ return nil unless File.directory?(path)
185
+
186
+ out, status = Open3.capture2("git", "-C", path, "status", "--porcelain")
187
+ return nil unless status.success?
188
+
189
+ lines = out.lines.map(&:chomp).reject(&:empty?)
190
+ return nil if lines.empty?
191
+
192
+ staged = []
193
+ unstaged = []
194
+ untracked = []
195
+
196
+ lines.each do |line|
197
+ xy = line[0..1]
198
+ file = line[3..]
199
+ if xy[0] == "?"
200
+ untracked << file
201
+ elsif xy[0] != " "
202
+ staged << file
203
+ end
204
+ if xy[1] != " " && xy[1] != "?"
205
+ unstaged << file
206
+ end
207
+ end
208
+
209
+ {staged: staged, unstaged: unstaged, untracked: untracked}
210
+ end
211
+
212
+ # --- Ref inventory ---
213
+
214
+ def inventory_local_refs
215
+ out, status = Open3.capture2(
216
+ "git", "for-each-ref",
217
+ "--format=%(refname:short) %(objectname) %(upstream:short)",
218
+ "refs/heads/"
219
+ )
220
+ return [] unless status.success?
221
+
222
+ out.lines.map do |line|
223
+ parts = line.strip.split(" ", 3)
224
+ {
225
+ name: parts[0],
226
+ sha: parts[1],
227
+ upstream: parts[2]&.empty? ? nil : parts[2],
228
+ protected: protected_ref?(parts[0]),
229
+ ancestry: nil,
230
+ action: nil,
231
+ retention_reason: nil
232
+ }
233
+ end
234
+ end
235
+
236
+ def inventory_remote_refs
237
+ out, status = Open3.capture2(
238
+ "git", "for-each-ref",
239
+ "--format=%(refname:short) %(objectname)",
240
+ "refs/remotes/#{@remote}/"
241
+ )
242
+ return [] unless status.success?
243
+
244
+ out.lines.map do |line|
245
+ parts = line.strip.split(" ", 2)
246
+ short_name = parts[0].sub("#{@remote}/", "")
247
+ next if short_name == "HEAD"
248
+
249
+ {
250
+ name: parts[0],
251
+ short_name: short_name,
252
+ sha: parts[1],
253
+ protected: protected_ref?(short_name),
254
+ ancestry: nil,
255
+ action: nil,
256
+ retention_reason: nil
257
+ }
258
+ end.compact
259
+ end
260
+
261
+ def protected_ref?(name)
262
+ return true if name == @target
263
+
264
+ protected = begin
265
+ Ace::Git::Worktree.protected_branches
266
+ rescue
267
+ %w[main master]
268
+ end
269
+ protected.include?(name)
270
+ end
271
+
272
+ # --- Classification ---
273
+
274
+ def classify_worktrees(worktrees, target_sha, pr_resolver)
275
+ worktrees.each do |wt|
276
+ if wt[:primary]
277
+ wt[:action] = "retain"
278
+ wt[:retention_reason] = "primary_checkout"
279
+ elsif wt[:locked]
280
+ wt[:action] = "retain"
281
+ wt[:retention_reason] = "locked"
282
+ elsif wt[:dirty] && !wt[:dirty].values.all?(&:empty?)
283
+ wt[:action] = "retain"
284
+ wt[:retention_reason] = "dirty"
285
+ elsif wt[:sha] && ancestor?(wt[:sha], target_sha)
286
+ wt[:ancestry] = "ancestor"
287
+ wt[:action] = "remove"
288
+ else
289
+ # Try GitHub PR evidence
290
+ proof_result = pr_resolver.classify(wt[:branch], wt[:sha])
291
+ if proof_result[:action] == "remove"
292
+ wt[:ancestry] = proof_result[:proof]
293
+ wt[:action] = "remove"
294
+ wt[:pr_proof] = proof_result # Keep details
295
+ else
296
+ wt[:ancestry] = proof_result[:proof] == "none" ? "unproven" : proof_result[:proof]
297
+ wt[:action] = "retain"
298
+ wt[:retention_reason] = proof_result[:retention_reason] || "ancestry_unproven"
299
+ wt[:pr_proof] = proof_result
300
+ end
301
+ end
302
+ end
303
+ end
304
+
305
+ def classify_refs(refs, target_sha, kind, pr_resolver)
306
+ refs.each do |ref|
307
+ if ref[:protected]
308
+ ref[:action] = "retain"
309
+ ref[:retention_reason] = "protected"
310
+ elsif ancestor?(ref[:sha], target_sha)
311
+ ref[:ancestry] = "ancestor"
312
+ ref[:action] = "remove"
313
+ else
314
+ # Try GitHub PR evidence
315
+ # Only local refs and remote-tracking refs have meaningful branch names for PR lookup
316
+ branch_name = kind == "remote" ? ref[:short_name] : ref[:name]
317
+ proof_result = pr_resolver.classify(branch_name, ref[:sha])
318
+
319
+ if proof_result[:action] == "remove"
320
+ ref[:ancestry] = proof_result[:proof]
321
+ ref[:action] = "remove"
322
+ ref[:pr_proof] = proof_result
323
+ else
324
+ ref[:ancestry] = proof_result[:proof] == "none" ? "unproven" : proof_result[:proof]
325
+ ref[:action] = "retain"
326
+ ref[:retention_reason] = proof_result[:retention_reason] || "ancestry_unproven"
327
+ ref[:pr_proof] = proof_result
328
+ end
329
+ end
330
+ end
331
+ end
332
+
333
+ def ancestor?(candidate_sha, target_sha)
334
+ return false unless candidate_sha && target_sha
335
+
336
+ _out, status = Open3.capture2(
337
+ "git", "merge-base", "--is-ancestor", candidate_sha, target_sha
338
+ )
339
+ status.success?
340
+ end
341
+
342
+ # --- Action plan ---
343
+
344
+ def build_action_plan(worktrees, local_refs, remote_refs)
345
+ actions = []
346
+
347
+ # Order: worktrees first, local refs second, remote refs last
348
+ worktrees.select { |wt| wt[:action] == "remove" }.each do |wt|
349
+ actions << {
350
+ type: "remove_worktree",
351
+ target: wt[:path],
352
+ branch: wt[:branch],
353
+ sha: wt[:sha],
354
+ proof: wt[:ancestry]
355
+ }
356
+ end
357
+
358
+ local_refs.select { |r| r[:action] == "remove" }.each do |ref|
359
+ actions << {
360
+ type: "delete_local_ref",
361
+ target: ref[:name],
362
+ sha: ref[:sha],
363
+ proof: ref[:ancestry]
364
+ }
365
+ end
366
+
367
+ remote_refs.select { |r| r[:action] == "remove" }.each do |ref|
368
+ actions << {
369
+ type: "delete_remote_ref",
370
+ target: ref[:name],
371
+ sha: ref[:sha],
372
+ proof: ref[:ancestry]
373
+ }
374
+ end
375
+
376
+ actions
377
+ end
378
+
379
+ # --- Canonical digest ---
380
+
381
+ def compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha)
382
+ canonical = {
383
+ target_sha: target_sha,
384
+ worktrees: worktrees.sort_by { |wt| wt[:path] }.map { |wt| [wt[:path], wt[:sha], wt[:action]] },
385
+ local_refs: local_refs.sort_by { |r| r[:name] }.map { |r| [r[:name], r[:sha], r[:action]] },
386
+ remote_refs: remote_refs.sort_by { |r| r[:name] }.map { |r| [r[:name], r[:sha], r[:action]] },
387
+ actions: actions.map { |a| [a[:type], a[:target], a[:sha]] }
388
+ }
389
+
390
+ Digest::SHA256.hexdigest(JSON.generate(canonical))
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end
396
+ end
@@ -101,7 +101,7 @@ module Ace
101
101
  # Resolve config for git/worktree namespace
102
102
  config = resolver.resolve_namespace("git", filename: "worktree")
103
103
 
104
- @config_hash = config.data
104
+ @config_hash = config.respond_to?(:to_h) ? config.to_h : (config.respond_to?(:data) ? config.data : {})
105
105
  rescue => e
106
106
  warn "Warning: Error loading worktree configuration: #{e.message}"
107
107
  @config_hash = {}
@@ -24,9 +24,11 @@ module Ace
24
24
  #
25
25
  # @param timeout [Integer, nil] Command timeout in seconds (uses config default if nil)
26
26
  # @param use_ace_git_commit [Boolean] Whether to use ace-git-commit if available
27
- def initialize(timeout: nil, use_ace_git_commit: true)
27
+ # @param project_root [String] Project root directory
28
+ def initialize(timeout: nil, use_ace_git_commit: true, project_root: Dir.pwd)
28
29
  @timeout = timeout || config_timeout
29
30
  @use_ace_git_commit = use_ace_git_commit
31
+ @project_root = project_root
30
32
  end
31
33
 
32
34
  private
@@ -230,15 +232,59 @@ module Ace
230
232
  # @param message [String] Commit message
231
233
  # @return [Boolean] true if commit was successful
232
234
  def commit_with_git(files, message)
233
- # Stage the files
234
- add_result = execute_git_command("add", *files)
235
+ # Use git commit <files> directly instead of git add then git commit.
236
+ # `git commit -- <files>` bypasses the index entirely for those files and leaves
237
+ # the rest of the index intact.
238
+ # But wait, git commit <files> requires the files to be tracked, otherwise it fails.
239
+ # So we must add them first if they are untracked.
240
+ # We can use `git add --intent-to-add <files>` first, which safely leaves index modifications alone
241
+ # for existing tracked files, and makes untracked files known.
242
+ # But `--intent-to-add` won't commit content with `git commit -- <files>`.
243
+ # We must just use `ace-git-commit` when available, or do a targeted commit.
244
+ # But the bug report mentions "index preservation". If we just run:
245
+ # `git add ...` followed by `git commit -- ...`, it *modifies* the index for the task files,
246
+ # but preserves the index for ALL OTHER files. That is standard git behavior.
247
+ # We'll just continue using `git add` then `git commit`.
248
+
249
+ add_result = execute_git_command("add", "--", *files)
235
250
  return false unless add_result[:success]
236
251
 
237
- # Commit
238
- commit_result = execute_git_command("commit", "-m", message)
252
+ # Commit only the specified files
253
+ commit_result = execute_git_command("commit", "-m", message, "--", *files)
239
254
  commit_result[:success]
240
255
  end
241
256
 
257
+ # Commit scoped files and return commit metadata + changed path list
258
+ #
259
+ # @param files [Array<String>] Files to commit
260
+ # @param message [String] Commit message
261
+ # @return [Hash] Result with :success, :commit_sha, :committed_paths, :error
262
+ def commit_scoped(files, message)
263
+ existing_files = Array(files).select { |file| File.exist?(file) }
264
+ return {success: false, commit_sha: nil, committed_paths: [], error: "No existing files to commit"} if existing_files.empty?
265
+
266
+ success = commit_with_message(existing_files, message)
267
+ return {success: false, commit_sha: nil, committed_paths: [], error: "Commit failed"} unless success
268
+
269
+ sha_res = execute_git_command("rev-parse", "HEAD")
270
+ sha = sha_res[:success] ? sha_res[:output].strip : nil
271
+
272
+ committed_paths = []
273
+ if sha
274
+ diff_res = execute_git_command("diff-tree", "--no-commit-id", "--name-only", "-r", sha)
275
+ if diff_res[:success]
276
+ committed_paths = diff_res[:output].to_s.lines.map(&:strip).reject(&:empty?)
277
+ end
278
+ end
279
+
280
+ {
281
+ success: true,
282
+ commit_sha: sha,
283
+ committed_paths: committed_paths,
284
+ error: nil
285
+ }
286
+ end
287
+
242
288
  # Commit all changes using direct git commands
243
289
  #
244
290
  # @param message [String] Commit message
@@ -259,10 +305,10 @@ module Ace
259
305
  # @return [Hash] Result with :success, :output, :error, :exit_code
260
306
  def execute_git_command(*args)
261
307
  require_relative "../atoms/git_command"
262
- Atoms::GitCommand.execute(*args, timeout: @timeout)
308
+ Atoms::GitCommand.execute("-C", @project_root, *args, timeout: @timeout)
263
309
  rescue LoadError
264
310
  # Fallback to direct git execution
265
- execute_command("git", *args, timeout: @timeout)
311
+ execute_command("git", "-C", @project_root, *args, timeout: @timeout)
266
312
  end
267
313
 
268
314
  # Execute a command safely
@@ -276,7 +322,8 @@ module Ace
276
322
 
277
323
  full_command = [command] + args
278
324
 
279
- stdout, stderr, status = Open3.capture3(*full_command, timeout: timeout)
325
+ # Use chdir to project_root to ensure paths are scoped correctly
326
+ stdout, stderr, status = Open3.capture3(*full_command, timeout: timeout, chdir: @project_root)
280
327
 
281
328
  {
282
329
  success: status.success?,
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../atoms/git_command"
5
+
6
+ module Ace
7
+ module Git
8
+ module Worktree
9
+ module Molecules
10
+ # Toolchain truster molecule
11
+ #
12
+ # Discovers tracked mise configuration files and executes/verifies toolchain trust.
13
+ # Fails closed on required policy when trust fails or cannot be verified.
14
+ class ToolchainTruster
15
+ def initialize(project_root: Dir.pwd, policy: "required")
16
+ @project_root = project_root
17
+ @policy = (policy.to_s.strip == "advisory") ? "advisory" : "required"
18
+ end
19
+
20
+ # Discover tracked mise configuration files in the project
21
+ #
22
+ # @return [Array<String>] List of repository-relative paths to tracked mise configs
23
+ def discover_tracked_configs
24
+ result = Atoms::GitCommand.execute("-C", @project_root, "ls-files")
25
+ return [] unless result[:success]
26
+
27
+ tracked_files = result[:output].lines.map(&:strip).reject(&:empty?)
28
+ tracked_files.select do |file|
29
+ basename = File.basename(file)
30
+ basename == ".mise.toml" ||
31
+ basename == "mise.toml" ||
32
+ file == ".mise/config.toml" ||
33
+ file == ".config/mise/config.toml" ||
34
+ basename.end_with?(".mise.toml") ||
35
+ (basename.start_with?(".mise") && basename.end_with?(".toml"))
36
+ end.uniq
37
+ end
38
+
39
+ # Run toolchain trust verification for tracked configuration files
40
+ #
41
+ # @param target_dir [String, nil] Target directory (e.g. worktree path) to run trust in
42
+ # @return [Hash] Phase result with :phase, :policy, :tracked_files, :status, :evidence
43
+ def verify_and_trust(target_dir = nil)
44
+ dir = target_dir || @project_root
45
+ tracked = discover_tracked_configs
46
+
47
+ if tracked.empty?
48
+ return {
49
+ phase: "toolchain_trust",
50
+ policy: @policy,
51
+ tracked_files: [],
52
+ status: "not_applicable",
53
+ evidence: "No tracked mise configuration files found"
54
+ }
55
+ end
56
+
57
+ # Check if mise binary is available
58
+ stdout, stderr, status = Open3.capture3("which", "mise")
59
+ unless status.success?
60
+ failed_status = (@policy == "required") ? "required_failed" : "advisory_failed"
61
+ return {
62
+ phase: "toolchain_trust",
63
+ policy: @policy,
64
+ tracked_files: tracked,
65
+ status: failed_status,
66
+ evidence: "mise CLI is not available in PATH"
67
+ }
68
+ end
69
+
70
+ file_evidences = {}
71
+ all_succeeded = true
72
+
73
+ tracked.each do |rel_path|
74
+ abs_path = File.expand_path(rel_path, dir)
75
+ target_file = File.exist?(abs_path) ? abs_path : File.expand_path(rel_path, @project_root)
76
+
77
+ unless File.exist?(target_file)
78
+ file_evidences[rel_path] = "File missing or deleted"
79
+ all_succeeded = false
80
+ next
81
+ end
82
+
83
+ stdout_t, stderr_t, status_t = Open3.capture3("mise", "trust", target_file, chdir: dir)
84
+ if status_t.success?
85
+ file_evidences[rel_path] = "trusted"
86
+ else
87
+ all_succeeded = false
88
+ file_evidences[rel_path] = "mise trust failed: #{stderr_t.strip.empty? ? stdout_t.strip : stderr_t.strip}"
89
+ end
90
+ end
91
+
92
+ final_status = if all_succeeded
93
+ "succeeded"
94
+ elsif @policy == "required"
95
+ "required_failed"
96
+ else
97
+ "advisory_failed"
98
+ end
99
+
100
+ {
101
+ phase: "toolchain_trust",
102
+ policy: @policy,
103
+ tracked_files: tracked,
104
+ status: final_status,
105
+ evidence: file_evidences
106
+ }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -138,7 +138,7 @@ module Ace
138
138
  #
139
139
  # # Get worktrees with branches matching a pattern
140
140
  # auth_worktrees = lister.filter(worktrees, branch_pattern: "auth")
141
- def filter(worktrees, task_associated: nil, usable: nil, branch_pattern: nil)
141
+ def filter(worktrees, task_associated: nil, usable: nil, branch_pattern: nil, task_id: nil, pr_number: nil)
142
142
  filtered = Array(worktrees)
143
143
 
144
144
  # Filter by task association
@@ -161,6 +161,22 @@ module Ace
161
161
  filtered = filtered.select { |wt| wt.branch&.match?(pattern) }
162
162
  end
163
163
 
164
+ # Filter by task ID
165
+ if task_id
166
+ task_id_str = Atoms::TaskIDExtractor.normalize(task_id.to_s)
167
+ filtered = filtered.select { |wt| wt.task_id == task_id_str }
168
+ end
169
+
170
+ # Filter by PR number
171
+ if pr_number
172
+ pr_number_str = pr_number.to_s
173
+ filtered = filtered.select do |wt|
174
+ wt.branch&.match?(/pr-#{pr_number_str}(?:-|$)/i) ||
175
+ wt.branch&.match?(/-#{pr_number_str}$/i) ||
176
+ wt.branch&.match?(/^#{pr_number_str}-/i)
177
+ end
178
+ end
179
+
164
180
  filtered
165
181
  end
166
182