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
|
@@ -106,6 +106,7 @@ module Ace
|
|
|
106
106
|
--no-push Skip pushing task changes to remote (task mode only)
|
|
107
107
|
--no-upstream Skip pushing worktree branch with upstream tracking (task mode only)
|
|
108
108
|
--no-pr Skip creating draft PR (task mode only)
|
|
109
|
+
--no-bootstrap Skip worktree bootstrap preparation
|
|
109
110
|
--push-remote <name> Remote to push to (default: origin) (task mode only)
|
|
110
111
|
--no-auto-navigate Stay in current directory (default: navigate to worktree)
|
|
111
112
|
--commit-message <msg> Custom commit message for task updates (task mode only)
|
|
@@ -173,6 +174,7 @@ module Ace
|
|
|
173
174
|
no_push: false,
|
|
174
175
|
no_upstream: false,
|
|
175
176
|
no_pr: false,
|
|
177
|
+
no_bootstrap: false,
|
|
176
178
|
push_remote: nil,
|
|
177
179
|
no_auto_navigate: false,
|
|
178
180
|
commit_message: nil,
|
|
@@ -213,6 +215,8 @@ module Ace
|
|
|
213
215
|
options[:no_upstream] = true
|
|
214
216
|
when "--no-pr"
|
|
215
217
|
options[:no_pr] = true
|
|
218
|
+
when "--no-bootstrap"
|
|
219
|
+
options[:no_bootstrap] = true
|
|
216
220
|
when "--push-remote"
|
|
217
221
|
i += 1
|
|
218
222
|
options[:push_remote] = args[i]
|
|
@@ -367,6 +371,7 @@ module Ace
|
|
|
367
371
|
no_push: options[:no_push],
|
|
368
372
|
no_upstream: options[:no_upstream],
|
|
369
373
|
no_pr: options[:no_pr],
|
|
374
|
+
no_bootstrap: options[:no_bootstrap],
|
|
370
375
|
push_remote: options[:push_remote],
|
|
371
376
|
commit_message: options[:commit_message],
|
|
372
377
|
target_branch: options[:target_branch],
|
|
@@ -61,6 +61,13 @@ module Ace
|
|
|
61
61
|
"on_merge" => false,
|
|
62
62
|
"on_delete" => true
|
|
63
63
|
},
|
|
64
|
+
"bootstrap" => {
|
|
65
|
+
"command" => nil,
|
|
66
|
+
"working_dir" => ".",
|
|
67
|
+
"timeout" => 60,
|
|
68
|
+
"policy" => "required",
|
|
69
|
+
"env" => {}
|
|
70
|
+
},
|
|
64
71
|
"hooks" => {
|
|
65
72
|
"after_create" => []
|
|
66
73
|
}
|
|
@@ -69,7 +76,7 @@ module Ace
|
|
|
69
76
|
# Configuration namespace paths
|
|
70
77
|
CONFIG_NAMESPACE = ["git", "worktree"].freeze
|
|
71
78
|
|
|
72
|
-
attr_reader :root_path, :auto_navigate, :tmux, :mise_trust_auto, :task_config, :pr_config, :branch_config, :cleanup_config, :hooks_config
|
|
79
|
+
attr_reader :root_path, :auto_navigate, :tmux, :mise_trust_auto, :task_config, :pr_config, :branch_config, :cleanup_config, :bootstrap_config, :hooks_config
|
|
73
80
|
|
|
74
81
|
# Initialize a new WorktreeConfig
|
|
75
82
|
#
|
|
@@ -370,11 +377,27 @@ module Ace
|
|
|
370
377
|
auto_navigate: auto_navigate?,
|
|
371
378
|
mise_trust_auto: mise_trust_auto?,
|
|
372
379
|
task: @task_config.dup,
|
|
380
|
+
bootstrap: @bootstrap_config.dup,
|
|
373
381
|
cleanup: @cleanup_config.dup,
|
|
374
382
|
hooks: @hooks_config.dup
|
|
375
383
|
}
|
|
376
384
|
end
|
|
377
385
|
|
|
386
|
+
# Get bootstrap configuration
|
|
387
|
+
#
|
|
388
|
+
# @return [Hash] Bootstrap configuration hash
|
|
389
|
+
def bootstrap
|
|
390
|
+
@bootstrap_config
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Check if bootstrap is configured with a command
|
|
394
|
+
#
|
|
395
|
+
# @return [Boolean] true if bootstrap command is set
|
|
396
|
+
def bootstrap_configured?
|
|
397
|
+
cmd = @bootstrap_config["command"]
|
|
398
|
+
!cmd.nil? && !cmd.to_s.strip.empty?
|
|
399
|
+
end
|
|
400
|
+
|
|
378
401
|
private
|
|
379
402
|
|
|
380
403
|
# Find invalid template variables in a template string
|
|
@@ -397,8 +420,14 @@ module Ace
|
|
|
397
420
|
# @param config_hash [Hash] Full configuration hash
|
|
398
421
|
# @return [Hash] Worktree-specific configuration
|
|
399
422
|
def extract_worktree_config(config_hash)
|
|
400
|
-
|
|
401
|
-
|
|
423
|
+
return {} unless config_hash.is_a?(Hash)
|
|
424
|
+
|
|
425
|
+
if config_hash.key?("git")
|
|
426
|
+
config_hash.dig("git", "worktree") || config_hash
|
|
427
|
+
elsif config_hash.key?("worktree")
|
|
428
|
+
config_hash.dig("worktree") || config_hash
|
|
429
|
+
else
|
|
430
|
+
config_hash
|
|
402
431
|
end
|
|
403
432
|
end
|
|
404
433
|
|
|
@@ -435,6 +464,7 @@ module Ace
|
|
|
435
464
|
@pr_config = @merged_config["pr"] || {}
|
|
436
465
|
@branch_config = @merged_config["branch"] || {}
|
|
437
466
|
@cleanup_config = @merged_config["cleanup"] || {}
|
|
467
|
+
@bootstrap_config = @merged_config["bootstrap"] || {}
|
|
438
468
|
@hooks_config = @merged_config["hooks"] || {}
|
|
439
469
|
end
|
|
440
470
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Git
|
|
8
|
+
module Worktree
|
|
9
|
+
module Molecules
|
|
10
|
+
# Molecule for executing worktree preparation bootstrap phase
|
|
11
|
+
class BootstrapExecutor
|
|
12
|
+
# Initialize BootstrapExecutor
|
|
13
|
+
#
|
|
14
|
+
# @param project_root [String] Project root path
|
|
15
|
+
# @param worktree_path [String] Target worktree path
|
|
16
|
+
# @param bootstrap_config [Hash] Resolved bootstrap policy configuration
|
|
17
|
+
# @param no_bootstrap [Boolean] Whether bootstrap was explicitly skipped
|
|
18
|
+
def initialize(project_root: Dir.pwd, worktree_path: nil, bootstrap_config: nil, no_bootstrap: false)
|
|
19
|
+
@project_root = project_root
|
|
20
|
+
@worktree_path = worktree_path || project_root
|
|
21
|
+
@config = bootstrap_config || {}
|
|
22
|
+
@no_bootstrap = no_bootstrap
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Execute or skip bootstrap phase
|
|
26
|
+
#
|
|
27
|
+
# @return [Hash] Phase ledger item
|
|
28
|
+
def run
|
|
29
|
+
policy = @config["policy"] || "required"
|
|
30
|
+
|
|
31
|
+
if @no_bootstrap
|
|
32
|
+
return {
|
|
33
|
+
phase: "bootstrap",
|
|
34
|
+
policy: policy,
|
|
35
|
+
status: "skipped",
|
|
36
|
+
command: nil,
|
|
37
|
+
duration: 0.0
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
command = @config["command"]
|
|
42
|
+
if command.nil? || command.to_s.strip.empty?
|
|
43
|
+
return {
|
|
44
|
+
phase: "bootstrap",
|
|
45
|
+
policy: policy,
|
|
46
|
+
status: "not_configured",
|
|
47
|
+
command: nil,
|
|
48
|
+
duration: 0.0
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
working_dir = @config["working_dir"] || "."
|
|
53
|
+
target_dir = File.expand_path(working_dir, @worktree_path)
|
|
54
|
+
timeout_sec = (@config["timeout"] || 60).to_i
|
|
55
|
+
env_vars = (@config["env"] || {}).transform_keys(&:to_s).transform_values(&:to_s)
|
|
56
|
+
|
|
57
|
+
start_time = Time.now
|
|
58
|
+
output = ""
|
|
59
|
+
exit_code = 1
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
Timeout.timeout(timeout_sec) do
|
|
63
|
+
stdout, stderr, proc_status = Open3.capture3(env_vars, command, chdir: target_dir)
|
|
64
|
+
output = "#{stdout}\n#{stderr}".strip
|
|
65
|
+
exit_code = proc_status.exitstatus || (proc_status.success? ? 0 : 1)
|
|
66
|
+
end
|
|
67
|
+
if exit_code == 0
|
|
68
|
+
status = "succeeded"
|
|
69
|
+
else
|
|
70
|
+
status = (policy == "advisory") ? "advisory_failed" : "required_failed"
|
|
71
|
+
end
|
|
72
|
+
rescue Timeout::Error
|
|
73
|
+
output = "Execution timed out after #{timeout_sec}s"
|
|
74
|
+
exit_code = 124
|
|
75
|
+
status = (policy == "advisory") ? "advisory_failed" : "required_failed"
|
|
76
|
+
rescue => e
|
|
77
|
+
output = "Execution error: #{e.message}"
|
|
78
|
+
exit_code = 1
|
|
79
|
+
status = (policy == "advisory") ? "advisory_failed" : "required_failed"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
duration = (Time.now - start_time).round(3)
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
phase: "bootstrap",
|
|
86
|
+
policy: policy,
|
|
87
|
+
status: status,
|
|
88
|
+
command: command,
|
|
89
|
+
working_dir: working_dir,
|
|
90
|
+
exit_code: exit_code,
|
|
91
|
+
output: output,
|
|
92
|
+
duration: duration
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Git
|
|
8
|
+
module Worktree
|
|
9
|
+
module Molecules
|
|
10
|
+
# Applies a reviewed worktree cleanup plan with strict drift guards.
|
|
11
|
+
class CleanupApplier
|
|
12
|
+
# @param report [Hash] The reconstructed cleanup report
|
|
13
|
+
# @param approved_digest [String] The approved plan digest
|
|
14
|
+
def initialize(report, approved_digest)
|
|
15
|
+
@report = report
|
|
16
|
+
@approved_digest = approved_digest
|
|
17
|
+
@ledger = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Execute the cleanup plan
|
|
21
|
+
# @return [Hash] Application result including the ledger and final strict rescan status
|
|
22
|
+
def apply
|
|
23
|
+
if @report[:plan_digest] != @approved_digest
|
|
24
|
+
return error_result("Digest mismatch. The repository state has drifted since approval. Expected: #{@approved_digest}, Got: #{@report[:plan_digest]}. Please review the new report.")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
actions = @report[:actions]
|
|
28
|
+
return success_result if actions.empty?
|
|
29
|
+
|
|
30
|
+
# Fixed order execution: worktrees, local refs, remote refs
|
|
31
|
+
worktree_actions = actions.select { |a| a[:type] == "remove_worktree" }
|
|
32
|
+
local_ref_actions = actions.select { |a| a[:type] == "delete_local_ref" }
|
|
33
|
+
remote_ref_actions = actions.select { |a| a[:type] == "delete_remote_ref" }
|
|
34
|
+
|
|
35
|
+
failed = false
|
|
36
|
+
|
|
37
|
+
worktree_actions.each do |action|
|
|
38
|
+
next if failed
|
|
39
|
+
failed = !execute_worktree_removal(action)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
local_ref_actions.each do |action|
|
|
43
|
+
next if failed
|
|
44
|
+
failed = !execute_local_ref_deletion(action)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
remote_ref_actions.each do |action|
|
|
48
|
+
next if failed
|
|
49
|
+
failed = !execute_remote_ref_deletion(action)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if failed
|
|
53
|
+
{
|
|
54
|
+
success: false,
|
|
55
|
+
error: "Cleanup partially failed. See ledger for details.",
|
|
56
|
+
ledger: @ledger,
|
|
57
|
+
partial_failure: true
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
success_result
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def success_result
|
|
67
|
+
{
|
|
68
|
+
success: true,
|
|
69
|
+
ledger: @ledger,
|
|
70
|
+
partial_failure: false
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def error_result(message)
|
|
75
|
+
{
|
|
76
|
+
success: false,
|
|
77
|
+
error: message,
|
|
78
|
+
ledger: @ledger,
|
|
79
|
+
partial_failure: false
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record_ledger(action, success, error = nil)
|
|
84
|
+
@ledger << {
|
|
85
|
+
action: action,
|
|
86
|
+
success: success,
|
|
87
|
+
error: error
|
|
88
|
+
}
|
|
89
|
+
success
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- Action Execution ---
|
|
93
|
+
|
|
94
|
+
def execute_worktree_removal(action)
|
|
95
|
+
path = action[:target]
|
|
96
|
+
expected_sha = action[:sha]
|
|
97
|
+
|
|
98
|
+
# Re-verify path safety (Trash adapter logic)
|
|
99
|
+
unless safe_trash_path?(path)
|
|
100
|
+
return record_ledger(action, false, "Unsafe worktree path: #{path}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Immediate drift guard: dirty check
|
|
104
|
+
dirty_result = dirty_state(path)
|
|
105
|
+
if dirty_result.nil?
|
|
106
|
+
return record_ledger(action, false, "Worktree path not found or not a git directory")
|
|
107
|
+
elsif !dirty_result.values.all?(&:empty?)
|
|
108
|
+
return record_ledger(action, false, "Worktree became dirty since report")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Immediate drift guard: sha check
|
|
112
|
+
current_sha = resolve_worktree_head(path)
|
|
113
|
+
if current_sha != expected_sha
|
|
114
|
+
return record_ledger(action, false, "Worktree HEAD changed (expected #{expected_sha}, got #{current_sha})")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Execute removal
|
|
118
|
+
_out, _err, status = Open3.capture3("git", "worktree", "remove", "--force", path)
|
|
119
|
+
unless status.success?
|
|
120
|
+
return record_ledger(action, false, "git worktree remove failed")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
record_ledger(action, true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def execute_local_ref_deletion(action)
|
|
127
|
+
ref = action[:target]
|
|
128
|
+
expected_sha = action[:sha]
|
|
129
|
+
|
|
130
|
+
# Immediate drift guard: check current SHA
|
|
131
|
+
current_sha = resolve_local_ref(ref)
|
|
132
|
+
if current_sha.nil?
|
|
133
|
+
# Already gone, consider it a success but note it
|
|
134
|
+
return record_ledger(action, true, "Ref was already deleted")
|
|
135
|
+
elsif current_sha != expected_sha
|
|
136
|
+
return record_ledger(action, false, "Ref SHA changed (expected #{expected_sha}, got #{current_sha})")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Execute deletion
|
|
140
|
+
_out, _err, status = Open3.capture3("git", "branch", "-D", ref)
|
|
141
|
+
unless status.success?
|
|
142
|
+
return record_ledger(action, false, "git branch -D failed")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
record_ledger(action, true)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def execute_remote_ref_deletion(action)
|
|
149
|
+
ref = action[:target] # e.g. origin/feature -> we need remote and branch
|
|
150
|
+
expected_sha = action[:sha]
|
|
151
|
+
|
|
152
|
+
# Action target for remote refs is typically "origin/feature"
|
|
153
|
+
parts = ref.split("/", 2)
|
|
154
|
+
remote = parts[0]
|
|
155
|
+
branch = parts[1]
|
|
156
|
+
|
|
157
|
+
# The exact lease will prevent deletion if remote ref has advanced
|
|
158
|
+
# git push <remote> --delete <branch> --force-with-lease=<branch>:<expected_sha>
|
|
159
|
+
cmd = ["git", "push", remote, "--delete", branch, "--force-with-lease=#{branch}:#{expected_sha}"]
|
|
160
|
+
_out, _err, status = Open3.capture3(*cmd)
|
|
161
|
+
|
|
162
|
+
unless status.success?
|
|
163
|
+
return record_ledger(action, false, "Remote push --delete with lease failed")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
record_ledger(action, true)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# --- Guards and Helpers ---
|
|
170
|
+
|
|
171
|
+
def safe_trash_path?(path)
|
|
172
|
+
# Rejects root, home, etc.
|
|
173
|
+
# In a real app this would delegate to a Trash adapter, but we validate safety here.
|
|
174
|
+
abs_path = File.expand_path(path)
|
|
175
|
+
return false if abs_path == "/"
|
|
176
|
+
return false if abs_path == File.expand_path("~")
|
|
177
|
+
|
|
178
|
+
# Must be within a project or temp space conceptually, but strictly we just need it to be a valid git dir
|
|
179
|
+
# that is not the main repository.
|
|
180
|
+
# We already have @report[:repository] (common_dir)
|
|
181
|
+
common_dir = File.expand_path(@report[:repository])
|
|
182
|
+
return false if abs_path == File.expand_path(File.dirname(common_dir))
|
|
183
|
+
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def dirty_state(path)
|
|
188
|
+
return nil unless File.directory?(path)
|
|
189
|
+
out, status = Open3.capture2("git", "-C", path, "status", "--porcelain")
|
|
190
|
+
return nil unless status.success?
|
|
191
|
+
|
|
192
|
+
lines = out.lines.map(&:chomp).reject(&:empty?)
|
|
193
|
+
staged = []
|
|
194
|
+
unstaged = []
|
|
195
|
+
untracked = []
|
|
196
|
+
|
|
197
|
+
lines.each do |line|
|
|
198
|
+
xy = line[0..1]
|
|
199
|
+
if xy[0] == "?"
|
|
200
|
+
untracked << line[3..]
|
|
201
|
+
elsif xy[0] != " "
|
|
202
|
+
staged << line[3..]
|
|
203
|
+
end
|
|
204
|
+
if xy[1] != " " && xy[1] != "?"
|
|
205
|
+
unstaged << line[3..]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
{staged: staged, unstaged: unstaged, untracked: untracked}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def resolve_worktree_head(path)
|
|
213
|
+
out, status = Open3.capture2("git", "-C", path, "rev-parse", "HEAD")
|
|
214
|
+
status.success? ? out.strip : nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def resolve_local_ref(ref)
|
|
218
|
+
out, status = Open3.capture2("git", "rev-parse", "--verify", "refs/heads/#{ref}^{commit}")
|
|
219
|
+
status.success? ? out.strip : nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "ace/git/molecules/pr_metadata_fetcher"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
module Worktree
|
|
10
|
+
module Molecules
|
|
11
|
+
# Resolves PR evidence for candidate branches to prove safe squash-merge cleanup.
|
|
12
|
+
class CleanupPrResolver
|
|
13
|
+
def initialize(target:, target_sha:, offline: false)
|
|
14
|
+
@target = target
|
|
15
|
+
@target_sha = target_sha
|
|
16
|
+
@offline = offline
|
|
17
|
+
|
|
18
|
+
# Cache gh availability to avoid repeated checks
|
|
19
|
+
@gh_available = Ace::Git::Molecules::PrMetadataFetcher.gh_installed? &&
|
|
20
|
+
Ace::Git::Molecules::PrMetadataFetcher.gh_authenticated?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Classify a candidate branch with GitHub PR evidence.
|
|
24
|
+
#
|
|
25
|
+
# @param branch [String] The branch name (e.g. "feature")
|
|
26
|
+
# @param candidate_sha [String] The commit SHA of the candidate
|
|
27
|
+
# @return [Hash] Proof classification result
|
|
28
|
+
def classify(branch, candidate_sha)
|
|
29
|
+
return offline_result unless @gh_available && !@offline
|
|
30
|
+
|
|
31
|
+
prs = fetch_merged_prs_for_branch(branch)
|
|
32
|
+
return unproven_result if prs.nil? || prs.empty?
|
|
33
|
+
|
|
34
|
+
# Find a PR that targets the configured base and is actually reachable
|
|
35
|
+
valid_pr = prs.find do |pr|
|
|
36
|
+
pr["baseRefName"] == @target &&
|
|
37
|
+
pr["state"] == "MERGED" &&
|
|
38
|
+
pr["mergeCommit"] &&
|
|
39
|
+
pr["mergeCommit"]["oid"] &&
|
|
40
|
+
ancestor?(pr["mergeCommit"]["oid"], @target_sha)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return ambiguous_result(prs) if prs.length > 1 && !valid_pr
|
|
44
|
+
return unproven_result unless valid_pr
|
|
45
|
+
|
|
46
|
+
merge_commit_sha = valid_pr["mergeCommit"]["oid"]
|
|
47
|
+
pr_head_sha = valid_pr["headRefOid"]
|
|
48
|
+
|
|
49
|
+
if candidate_sha == pr_head_sha
|
|
50
|
+
{
|
|
51
|
+
proof: "exact_merged_pr_head",
|
|
52
|
+
pr: valid_pr["number"],
|
|
53
|
+
candidate_head: candidate_sha,
|
|
54
|
+
merged_head: pr_head_sha,
|
|
55
|
+
merge_commit: merge_commit_sha,
|
|
56
|
+
target_reachable: true,
|
|
57
|
+
path_type_mode_match: true,
|
|
58
|
+
provider_status: "available",
|
|
59
|
+
action: "remove"
|
|
60
|
+
}
|
|
61
|
+
elsif patch_equivalent?(candidate_sha, merge_commit_sha)
|
|
62
|
+
{
|
|
63
|
+
proof: "stable_patch_equivalence",
|
|
64
|
+
pr: valid_pr["number"],
|
|
65
|
+
candidate_head: candidate_sha,
|
|
66
|
+
merged_head: pr_head_sha,
|
|
67
|
+
merge_commit: merge_commit_sha,
|
|
68
|
+
target_reachable: true,
|
|
69
|
+
path_type_mode_match: true,
|
|
70
|
+
provider_status: "available",
|
|
71
|
+
action: "remove"
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
{
|
|
75
|
+
proof: "none",
|
|
76
|
+
pr: valid_pr["number"],
|
|
77
|
+
candidate_head: candidate_sha,
|
|
78
|
+
merged_head: pr_head_sha,
|
|
79
|
+
merge_commit: merge_commit_sha,
|
|
80
|
+
target_reachable: true,
|
|
81
|
+
path_type_mode_match: false,
|
|
82
|
+
provider_status: "available",
|
|
83
|
+
action: "retain",
|
|
84
|
+
retention_reason: "patch_mismatch"
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
rescue Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError, Ace::Git::TimeoutError
|
|
88
|
+
# Provider failure degradation
|
|
89
|
+
offline_result("unavailable")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def fetch_merged_prs_for_branch(branch)
|
|
95
|
+
# Use `gh pr list --head <branch> --state merged` to find the exact PR
|
|
96
|
+
cmd = [
|
|
97
|
+
"gh", "pr", "list", "--state", "merged", "--head", branch,
|
|
98
|
+
"--json", "number,state,headRefOid,mergeCommit,baseRefName"
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
# Using Open3 directly to avoid circular dependencies if we don't want to augment ace-git too heavily for just this
|
|
102
|
+
# but we can rely on LC_ALL=C for safety.
|
|
103
|
+
env = {"LC_ALL" => "C"}
|
|
104
|
+
out, err, status = Open3.capture3(env, *cmd)
|
|
105
|
+
|
|
106
|
+
return nil unless status.success?
|
|
107
|
+
|
|
108
|
+
JSON.parse(out)
|
|
109
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ancestor?(candidate, target)
|
|
114
|
+
out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
|
|
115
|
+
status.success?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def patch_equivalent?(candidate_sha, merge_commit_sha)
|
|
119
|
+
# 1. Candidate patch inventory
|
|
120
|
+
# We want the diff between the candidate's merge-base with target, and the candidate itself
|
|
121
|
+
base, status = Open3.capture2("git", "merge-base", candidate_sha, @target_sha)
|
|
122
|
+
return false unless status.success?
|
|
123
|
+
base = base.strip
|
|
124
|
+
|
|
125
|
+
# Candidate inventory: path, type, mode changes
|
|
126
|
+
cand_inv = tree_diff_inventory(base, candidate_sha)
|
|
127
|
+
return false unless cand_inv
|
|
128
|
+
|
|
129
|
+
# 2. Merged commit patch inventory
|
|
130
|
+
# We want the diff between the merge commit's first parent, and the merge commit itself
|
|
131
|
+
mc_inv = tree_diff_inventory("#{merge_commit_sha}^1", merge_commit_sha)
|
|
132
|
+
return false unless mc_inv
|
|
133
|
+
|
|
134
|
+
cand_inv == mc_inv
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def tree_diff_inventory(tree_a, tree_b)
|
|
138
|
+
# git diff-tree -r --name-status or raw mode
|
|
139
|
+
# using raw mode to get exact mode and type
|
|
140
|
+
# :100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M file
|
|
141
|
+
out, status = Open3.capture2("git", "diff-tree", "-r", "--no-commit-id", tree_a, tree_b)
|
|
142
|
+
return nil unless status.success?
|
|
143
|
+
|
|
144
|
+
inventory = {}
|
|
145
|
+
out.each_line do |line|
|
|
146
|
+
# Format: :src_mode dst_mode src_sha dst_sha status\tpath
|
|
147
|
+
parts = line.strip.split("\t", 2)
|
|
148
|
+
meta = parts[0].split(" ")
|
|
149
|
+
path = parts[1]
|
|
150
|
+
|
|
151
|
+
dst_mode = meta[1]
|
|
152
|
+
dst_sha = meta[3]
|
|
153
|
+
status_char = meta[4]
|
|
154
|
+
|
|
155
|
+
inventory[path] = {
|
|
156
|
+
mode: dst_mode,
|
|
157
|
+
sha: dst_sha,
|
|
158
|
+
status: status_char[0] # handle R100 etc by taking first char
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
inventory
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def offline_result(status = "offline")
|
|
166
|
+
{
|
|
167
|
+
proof: "none",
|
|
168
|
+
pr: nil,
|
|
169
|
+
candidate_head: nil,
|
|
170
|
+
merged_head: nil,
|
|
171
|
+
merge_commit: nil,
|
|
172
|
+
target_reachable: "unknown",
|
|
173
|
+
path_type_mode_match: "unknown",
|
|
174
|
+
provider_status: status,
|
|
175
|
+
action: "retain",
|
|
176
|
+
retention_reason: "ancestry_unproven"
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def unproven_result
|
|
181
|
+
{
|
|
182
|
+
proof: "none",
|
|
183
|
+
pr: nil,
|
|
184
|
+
candidate_head: nil,
|
|
185
|
+
merged_head: nil,
|
|
186
|
+
merge_commit: nil,
|
|
187
|
+
target_reachable: "unknown",
|
|
188
|
+
path_type_mode_match: "unknown",
|
|
189
|
+
provider_status: "available",
|
|
190
|
+
action: "retain",
|
|
191
|
+
retention_reason: "ancestry_unproven"
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def ambiguous_result(prs)
|
|
196
|
+
{
|
|
197
|
+
proof: "none",
|
|
198
|
+
pr: nil,
|
|
199
|
+
candidate_head: nil,
|
|
200
|
+
merged_head: nil,
|
|
201
|
+
merge_commit: nil,
|
|
202
|
+
target_reachable: "unknown",
|
|
203
|
+
path_type_mode_match: "unknown",
|
|
204
|
+
provider_status: "ambiguous",
|
|
205
|
+
action: "retain",
|
|
206
|
+
retention_reason: "ambiguous_prs"
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|