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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -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 +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
|
-
|
|
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
|
-
#
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|