ace-git-worktree 0.22.1 → 0.24.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 +52 -0
- data/docs/usage.md +62 -10
- data/lib/ace/git/worktree/commands/cleanup_command.rb +49 -25
- data/lib/ace/git/worktree/commands/create_command.rb +70 -155
- data/lib/ace/git/worktree/molecules/cleanup_pr_resolver.rb +141 -132
- data/lib/ace/git/worktree/molecules/cleanup_reporter.rb +49 -11
- data/lib/ace/git/worktree/molecules/pull_request_checkout_preparer.rb +100 -0
- data/lib/ace/git/worktree/molecules/pull_request_creator.rb +67 -0
- data/lib/ace/git/worktree/molecules/pull_request_evidence_resolver.rb +40 -0
- data/lib/ace/git/worktree/molecules/worktree_creator.rb +52 -20
- data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +43 -9
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +55 -11
- data/lib/ace/git/worktree/version.rb +1 -1
- data/lib/ace/git/worktree.rb +3 -1
- metadata +7 -5
- data/lib/ace/git/worktree/molecules/pr_creator.rb +0 -253
|
@@ -2,116 +2,173 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "open3"
|
|
5
|
-
require "ace/git
|
|
5
|
+
require "ace/git"
|
|
6
6
|
|
|
7
7
|
module Ace
|
|
8
8
|
module Git
|
|
9
9
|
module Worktree
|
|
10
10
|
module Molecules
|
|
11
|
-
# Resolves PR evidence for candidate branches
|
|
11
|
+
# Resolves PR evidence for candidate branches through the registered
|
|
12
|
+
# forge provider to prove safe merge cleanup.
|
|
13
|
+
#
|
|
14
|
+
# Only confirmed remote merge evidence (:merged) can prove removal;
|
|
15
|
+
# object states (:no_pr, :open, :closed_unmerged) and transport
|
|
16
|
+
# failures (:offline, :authentication_error, :malformed) always
|
|
17
|
+
# retain the candidate. Local ancestry proof is handled by the
|
|
18
|
+
# reporter before this resolver is consulted.
|
|
12
19
|
class CleanupPrResolver
|
|
13
|
-
|
|
20
|
+
# @param target [String] base ref the PR must have targeted
|
|
21
|
+
# @param target_sha [String] commit SHA of the target
|
|
22
|
+
# @param offline [Boolean] skip provider evidence entirely
|
|
23
|
+
# @param server_name [String, nil] explicit server selection
|
|
24
|
+
# @param use_default [Boolean] resolve the configured default server
|
|
25
|
+
# @param remote_name [String, nil] remote used for fallback resolution
|
|
26
|
+
# @param timeout [Integer, nil] provider timeout
|
|
27
|
+
# @param runner [Proc, nil] injectable provider runner (tests)
|
|
28
|
+
def initialize(target:, target_sha:, offline: false, server_name: nil, use_default: false,
|
|
29
|
+
remote_name: nil, timeout: nil, runner: nil)
|
|
14
30
|
@target = target
|
|
15
31
|
@target_sha = target_sha
|
|
16
32
|
@offline = offline
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@
|
|
20
|
-
Ace::Git::Molecules::PrMetadataFetcher.gh_authenticated?
|
|
33
|
+
@selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
|
|
34
|
+
@timeout = timeout
|
|
35
|
+
@runner = runner
|
|
21
36
|
end
|
|
22
37
|
|
|
23
|
-
#
|
|
38
|
+
# @return [Ace::Git::ResolvedServer, nil] the server resolved for
|
|
39
|
+
# provider evidence; nil when unresolvable (local proof only)
|
|
40
|
+
def resolved_server
|
|
41
|
+
resolve_provider
|
|
42
|
+
@resolved_server
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Classify a candidate branch with provider PR evidence.
|
|
24
46
|
#
|
|
25
|
-
# @param branch [String]
|
|
26
|
-
# @param candidate_sha [String]
|
|
27
|
-
# @return [Hash]
|
|
47
|
+
# @param branch [String] the branch name (e.g. "feature")
|
|
48
|
+
# @param candidate_sha [String] the commit SHA of the candidate
|
|
49
|
+
# @return [Hash] proof classification; :status is one of
|
|
50
|
+
# Ace::Git::CLEANUP_PROOF_STATUSES
|
|
28
51
|
def classify(branch, candidate_sha)
|
|
29
|
-
return
|
|
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
|
|
52
|
+
return unproven_result(:no_pr, "provider_disabled") if @offline
|
|
42
53
|
|
|
43
|
-
|
|
44
|
-
return
|
|
54
|
+
evidence = fetch_merged_evidence(branch)
|
|
55
|
+
return failure_result(evidence) if evidence.is_a?(Hash) # transport failure marker
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
return unproven_result(:no_pr) unless evidence
|
|
58
|
+
return unproven_result(:open) if evidence.state == :open
|
|
59
|
+
return unproven_result(:closed_unmerged) if evidence.state == :closed
|
|
60
|
+
return unproven_result(:no_pr, "state_unavailable") unless evidence.state == :merged
|
|
61
|
+
return unproven_result(:merged, "merge_commit_unavailable") unless evidence.merge_commit_sha
|
|
62
|
+
return unproven_result(:merged, "merge_commit_unreachable") unless ancestor?(evidence.merge_commit_sha, @target_sha)
|
|
63
|
+
|
|
64
|
+
merge_commit_sha = evidence.merge_commit_sha
|
|
65
|
+
pr_head_sha = evidence.head_sha
|
|
48
66
|
|
|
49
67
|
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
|
-
}
|
|
68
|
+
proof_result(evidence, "exact_merged_pr_head", candidate_sha, pr_head_sha, merge_commit_sha)
|
|
61
69
|
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
|
-
}
|
|
70
|
+
proof_result(evidence, "stable_patch_equivalence", candidate_sha, pr_head_sha, merge_commit_sha)
|
|
73
71
|
else
|
|
74
72
|
{
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
status: :merged,
|
|
74
|
+
proof: nil,
|
|
75
|
+
pr: evidence.number,
|
|
76
|
+
pr_url: evidence.url,
|
|
77
77
|
candidate_head: candidate_sha,
|
|
78
78
|
merged_head: pr_head_sha,
|
|
79
79
|
merge_commit: merge_commit_sha,
|
|
80
80
|
target_reachable: true,
|
|
81
|
-
path_type_mode_match: false,
|
|
82
81
|
provider_status: "available",
|
|
83
82
|
action: "retain",
|
|
84
83
|
retention_reason: "patch_mismatch"
|
|
85
84
|
}
|
|
86
85
|
end
|
|
87
|
-
rescue Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError, Ace::Git::TimeoutError
|
|
88
|
-
# Provider failure degradation
|
|
89
|
-
offline_result("unavailable")
|
|
90
86
|
end
|
|
91
87
|
|
|
92
88
|
private
|
|
93
89
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
90
|
+
# Fetch the merged PR evidence for a branch, or a failure marker
|
|
91
|
+
# hash {:failure_status => ...} on classified transport failures.
|
|
92
|
+
def fetch_merged_evidence(branch)
|
|
93
|
+
provider = resolve_provider
|
|
94
|
+
return {failure_status: :offline} unless provider
|
|
95
|
+
|
|
96
|
+
provider.pull_request_for_branch(branch: branch)
|
|
97
|
+
rescue Ace::Git::ProviderAuthenticationError
|
|
98
|
+
{failure_status: :authentication_error}
|
|
99
|
+
rescue Ace::Git::ProviderMalformedOutputError
|
|
100
|
+
{failure_status: :malformed}
|
|
101
|
+
rescue Ace::Git::Error
|
|
102
|
+
{failure_status: :offline}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Resolve the provider lazily, once per resolver. Unresolvable
|
|
106
|
+
# server selection (no config, ambiguous remote, missing provider)
|
|
107
|
+
# means no provider evidence is available.
|
|
108
|
+
def resolve_provider
|
|
109
|
+
return @provider if defined?(@provider)
|
|
110
|
+
|
|
111
|
+
@provider = begin
|
|
112
|
+
server = Ace::Git::ServerRegistry.resolve_for(**@selection)
|
|
113
|
+
@resolved_server = server
|
|
114
|
+
Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
|
|
115
|
+
rescue Ace::Git::Error
|
|
116
|
+
@resolved_server = nil
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def proof_result(evidence, proof, candidate_sha, pr_head_sha, merge_commit_sha)
|
|
122
|
+
{
|
|
123
|
+
status: :merged,
|
|
124
|
+
proof: proof,
|
|
125
|
+
pr: evidence.number,
|
|
126
|
+
pr_url: evidence.url,
|
|
127
|
+
candidate_head: candidate_sha,
|
|
128
|
+
merged_head: pr_head_sha,
|
|
129
|
+
merge_commit: merge_commit_sha,
|
|
130
|
+
target_reachable: true,
|
|
131
|
+
provider_status: "available",
|
|
132
|
+
action: "remove",
|
|
133
|
+
retention_reason: nil
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unproven_result(status, reason = "ancestry_unproven")
|
|
138
|
+
{
|
|
139
|
+
status: status,
|
|
140
|
+
proof: nil,
|
|
141
|
+
pr: nil,
|
|
142
|
+
pr_url: nil,
|
|
143
|
+
candidate_head: nil,
|
|
144
|
+
merged_head: nil,
|
|
145
|
+
merge_commit: nil,
|
|
146
|
+
target_reachable: "unknown",
|
|
147
|
+
provider_status: "available",
|
|
148
|
+
action: "retain",
|
|
149
|
+
retention_reason: reason
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def failure_result(marker)
|
|
154
|
+
status = marker[:failure_status]
|
|
155
|
+
{
|
|
156
|
+
status: status,
|
|
157
|
+
proof: nil,
|
|
158
|
+
pr: nil,
|
|
159
|
+
pr_url: nil,
|
|
160
|
+
candidate_head: nil,
|
|
161
|
+
merged_head: nil,
|
|
162
|
+
merge_commit: nil,
|
|
163
|
+
target_reachable: "unknown",
|
|
164
|
+
provider_status: status.to_s,
|
|
165
|
+
action: "retain",
|
|
166
|
+
retention_reason: "provider_evidence_unavailable"
|
|
167
|
+
}
|
|
111
168
|
end
|
|
112
169
|
|
|
113
170
|
def ancestor?(candidate, target)
|
|
114
|
-
|
|
171
|
+
_out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
|
|
115
172
|
status.success?
|
|
116
173
|
end
|
|
117
174
|
|
|
@@ -121,7 +178,7 @@ module Ace
|
|
|
121
178
|
base, status = Open3.capture2("git", "merge-base", candidate_sha, @target_sha)
|
|
122
179
|
return false unless status.success?
|
|
123
180
|
base = base.strip
|
|
124
|
-
|
|
181
|
+
|
|
125
182
|
# Candidate inventory: path, type, mode changes
|
|
126
183
|
cand_inv = tree_diff_inventory(base, candidate_sha)
|
|
127
184
|
return false unless cand_inv
|
|
@@ -135,9 +192,8 @@ module Ace
|
|
|
135
192
|
end
|
|
136
193
|
|
|
137
194
|
def tree_diff_inventory(tree_a, tree_b)
|
|
138
|
-
# git diff-tree -r
|
|
139
|
-
#
|
|
140
|
-
# :100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M file
|
|
195
|
+
# git diff-tree -r raw mode carries exact mode and type information
|
|
196
|
+
# :100644 100644 e69de29... 000000... M file
|
|
141
197
|
out, status = Open3.capture2("git", "diff-tree", "-r", "--no-commit-id", tree_a, tree_b)
|
|
142
198
|
return nil unless status.success?
|
|
143
199
|
|
|
@@ -145,66 +201,19 @@ module Ace
|
|
|
145
201
|
out.each_line do |line|
|
|
146
202
|
# Format: :src_mode dst_mode src_sha dst_sha status\tpath
|
|
147
203
|
parts = line.strip.split("\t", 2)
|
|
204
|
+
next if parts.length < 2
|
|
205
|
+
|
|
148
206
|
meta = parts[0].split(" ")
|
|
149
207
|
path = parts[1]
|
|
150
|
-
|
|
151
|
-
dst_mode = meta[1]
|
|
152
|
-
dst_sha = meta[3]
|
|
153
|
-
status_char = meta[4]
|
|
154
|
-
|
|
208
|
+
|
|
155
209
|
inventory[path] = {
|
|
156
|
-
mode:
|
|
157
|
-
sha:
|
|
158
|
-
status:
|
|
210
|
+
mode: meta[1],
|
|
211
|
+
sha: meta[3],
|
|
212
|
+
status: meta[4][0] # handle R100 etc by taking first char
|
|
159
213
|
}
|
|
160
214
|
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
215
|
|
|
180
|
-
|
|
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
|
-
}
|
|
216
|
+
inventory
|
|
208
217
|
end
|
|
209
218
|
end
|
|
210
219
|
end
|
|
@@ -19,12 +19,22 @@ module Ace
|
|
|
19
19
|
SCHEMA_VERSION = "1.0"
|
|
20
20
|
|
|
21
21
|
# @param target [String] Target ref (e.g. "main", "origin/main")
|
|
22
|
-
# @param remote [String] Remote name (e.g. "origin")
|
|
22
|
+
# @param remote [String] Remote name (e.g. "origin"); identifies the
|
|
23
|
+
# local Git remote, never the forge
|
|
23
24
|
# @param offline [Boolean] Skip remote refresh
|
|
24
|
-
|
|
25
|
+
# @param server_name [String, nil] explicit forge server selection
|
|
26
|
+
# @param use_default [Boolean] resolve the configured default server
|
|
27
|
+
# @param remote_name [String, nil] remote used for fallback resolution
|
|
28
|
+
# @param timeout [Integer, nil] provider timeout
|
|
29
|
+
# @param runner [Proc, nil] injectable provider runner (tests)
|
|
30
|
+
def initialize(target:, remote: "origin", offline: false, server_name: nil, use_default: false,
|
|
31
|
+
remote_name: nil, timeout: nil, runner: nil)
|
|
25
32
|
@target = target
|
|
26
33
|
@remote = remote
|
|
27
34
|
@offline = offline
|
|
35
|
+
@selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
|
|
36
|
+
@timeout = timeout
|
|
37
|
+
@runner = runner
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
# Build complete cleanup report.
|
|
@@ -46,7 +56,11 @@ module Ace
|
|
|
46
56
|
local_refs = inventory_local_refs
|
|
47
57
|
remote_refs = inventory_remote_refs
|
|
48
58
|
|
|
49
|
-
pr_resolver = CleanupPrResolver.new(
|
|
59
|
+
pr_resolver = CleanupPrResolver.new(
|
|
60
|
+
target: @target, target_sha: target_sha, offline: @offline,
|
|
61
|
+
server_name: @selection[:server_name], use_default: @selection[:use_default],
|
|
62
|
+
remote_name: @selection[:remote_name], timeout: @timeout, runner: @runner
|
|
63
|
+
)
|
|
50
64
|
|
|
51
65
|
# Classify each item
|
|
52
66
|
classify_worktrees(worktrees, target_sha, pr_resolver)
|
|
@@ -56,8 +70,12 @@ module Ace
|
|
|
56
70
|
# Build ordered action plan
|
|
57
71
|
actions = build_action_plan(worktrees, local_refs, remote_refs)
|
|
58
72
|
|
|
59
|
-
#
|
|
60
|
-
|
|
73
|
+
# The resolved server identity binds provider proof to the plan;
|
|
74
|
+
# changed server configuration invalidates approval.
|
|
75
|
+
server = server_identity_hash(pr_resolver)
|
|
76
|
+
|
|
77
|
+
# Compute canonical digest (server/PR/head evidence included)
|
|
78
|
+
plan_digest = compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha, server)
|
|
61
79
|
|
|
62
80
|
{
|
|
63
81
|
success: true,
|
|
@@ -65,6 +83,7 @@ module Ace
|
|
|
65
83
|
repository: common_dir,
|
|
66
84
|
target: {ref: @target, sha: target_sha},
|
|
67
85
|
remote: {name: @remote, sha: remote_sha},
|
|
86
|
+
server: server,
|
|
68
87
|
refresh: @offline ? {status: "offline"} : (refresh_result || {status: "skipped"}),
|
|
69
88
|
worktrees: worktrees,
|
|
70
89
|
local_refs: local_refs,
|
|
@@ -286,7 +305,7 @@ module Ace
|
|
|
286
305
|
wt[:ancestry] = "ancestor"
|
|
287
306
|
wt[:action] = "remove"
|
|
288
307
|
else
|
|
289
|
-
# Try
|
|
308
|
+
# Try provider PR evidence
|
|
290
309
|
proof_result = pr_resolver.classify(wt[:branch], wt[:sha])
|
|
291
310
|
if proof_result[:action] == "remove"
|
|
292
311
|
wt[:ancestry] = proof_result[:proof]
|
|
@@ -311,7 +330,7 @@ module Ace
|
|
|
311
330
|
ref[:ancestry] = "ancestor"
|
|
312
331
|
ref[:action] = "remove"
|
|
313
332
|
else
|
|
314
|
-
# Try
|
|
333
|
+
# Try provider PR evidence
|
|
315
334
|
# Only local refs and remote-tracking refs have meaningful branch names for PR lookup
|
|
316
335
|
branch_name = kind == "remote" ? ref[:short_name] : ref[:name]
|
|
317
336
|
proof_result = pr_resolver.classify(branch_name, ref[:sha])
|
|
@@ -378,17 +397,36 @@ module Ace
|
|
|
378
397
|
|
|
379
398
|
# --- Canonical digest ---
|
|
380
399
|
|
|
381
|
-
|
|
400
|
+
# Resolved server identity for the report/digest; nil when no
|
|
401
|
+
# provider was resolvable (local ancestry proof only).
|
|
402
|
+
def server_identity_hash(pr_resolver)
|
|
403
|
+
server = pr_resolver.resolved_server
|
|
404
|
+
return nil unless server
|
|
405
|
+
|
|
406
|
+
{name: server.name, provider: server.provider.to_s, url: server.url}
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha, server)
|
|
382
410
|
canonical = {
|
|
411
|
+
schema_version: SCHEMA_VERSION,
|
|
383
412
|
target_sha: target_sha,
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
413
|
+
server: server,
|
|
414
|
+
worktrees: worktrees.sort_by { |wt| wt[:path] }.map { |wt| proof_tuple(wt[:path], wt) },
|
|
415
|
+
local_refs: local_refs.sort_by { |r| r[:name] }.map { |r| proof_tuple(r[:name], r) },
|
|
416
|
+
remote_refs: remote_refs.sort_by { |r| r[:name] }.map { |r| proof_tuple(r[:name], r) },
|
|
387
417
|
actions: actions.map { |a| [a[:type], a[:target], a[:sha]] }
|
|
388
418
|
}
|
|
389
419
|
|
|
390
420
|
Digest::SHA256.hexdigest(JSON.generate(canonical))
|
|
391
421
|
end
|
|
422
|
+
|
|
423
|
+
# One canonical entry per item: identity, decision, and the full
|
|
424
|
+
# provider proof that justified it.
|
|
425
|
+
def proof_tuple(identity, item)
|
|
426
|
+
proof = item[:pr_proof]
|
|
427
|
+
[identity, item[:sha], item[:action], item[:ancestry],
|
|
428
|
+
proof ? [proof[:status], proof[:pr], proof[:merged_head], proof[:merge_commit]] : nil]
|
|
429
|
+
end
|
|
392
430
|
end
|
|
393
431
|
end
|
|
394
432
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Worktree
|
|
8
|
+
module Molecules
|
|
9
|
+
# Fetches the exact source repository/ref declared by PR evidence and
|
|
10
|
+
# proves the fetched commit before any worktree is created.
|
|
11
|
+
#
|
|
12
|
+
# Canonical-repository and fork source URLs are equally valid: the
|
|
13
|
+
# fetch goes through a matching local remote when one exists, and by
|
|
14
|
+
# explicit URL otherwise (no remote ref is created for URL fetches).
|
|
15
|
+
class PullRequestCheckoutPreparer
|
|
16
|
+
DEFAULT_TIMEOUT = 60
|
|
17
|
+
|
|
18
|
+
# @param timeout [Integer] git command timeout in seconds
|
|
19
|
+
def initialize(timeout: DEFAULT_TIMEOUT)
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Fetch the declared source ref and verify its SHA against the
|
|
24
|
+
# evidence head.
|
|
25
|
+
#
|
|
26
|
+
# @param evidence [Ace::Git::ProviderPullRequest] normalized PR evidence
|
|
27
|
+
# @return [Hash] {success:, local_ref:, sha:, remote_tracking:, error:}
|
|
28
|
+
# `local_ref` is the exact verified commit SHA (immutable), safe
|
|
29
|
+
# for `git worktree add` even if later fetches move FETCH_HEAD.
|
|
30
|
+
def prepare(evidence)
|
|
31
|
+
head_url = evidence.head_repository_url
|
|
32
|
+
head_ref = evidence.head_ref
|
|
33
|
+
return error_result("PR evidence is missing the source repository URL") if head_url.nil? || head_url.empty?
|
|
34
|
+
return error_result("PR evidence is missing the source branch") if head_ref.nil? || head_ref.empty?
|
|
35
|
+
return error_result("PR evidence is missing the exact head SHA") unless evidence.head_sha
|
|
36
|
+
|
|
37
|
+
local_remote = matching_local_remote(head_url)
|
|
38
|
+
fetch_source = local_remote || head_url
|
|
39
|
+
fetch_desc = "#{head_url}@#{head_ref}"
|
|
40
|
+
|
|
41
|
+
fetch_result = Ace::Git::Worktree::Atoms::GitCommand.execute(
|
|
42
|
+
"fetch", fetch_source, head_ref, timeout: @timeout
|
|
43
|
+
)
|
|
44
|
+
return error_result("Failed to fetch #{fetch_desc}: #{fetch_result[:error]}") unless fetch_result[:success]
|
|
45
|
+
|
|
46
|
+
sha = resolve_fetch_head
|
|
47
|
+
return error_result("Cannot resolve fetched commit for #{fetch_desc}") unless sha
|
|
48
|
+
|
|
49
|
+
if sha != evidence.head_sha
|
|
50
|
+
return error_result(
|
|
51
|
+
"Fetched head #{sha} does not match PR evidence head #{evidence.head_sha} " \
|
|
52
|
+
"(#{head_url}@#{head_ref}); the PR head moved since the evidence was captured"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
success: true,
|
|
58
|
+
# The immutable verified SHA, not FETCH_HEAD: a later fetch must
|
|
59
|
+
# never be able to change what the worktree is created from.
|
|
60
|
+
local_ref: sha,
|
|
61
|
+
sha: sha,
|
|
62
|
+
remote_tracking: local_remote ? "#{local_remote}/#{head_ref}" : nil,
|
|
63
|
+
error: nil
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Find a configured local git remote whose URL matches the evidence
|
|
70
|
+
# source repository; nil means fetch by explicit URL instead.
|
|
71
|
+
def matching_local_remote(head_url)
|
|
72
|
+
result = Ace::Git::Worktree::Atoms::GitCommand.execute("remote", "-v", timeout: 5)
|
|
73
|
+
return nil unless result[:success]
|
|
74
|
+
|
|
75
|
+
remotes = {}
|
|
76
|
+
result[:output].to_s.each_line do |line|
|
|
77
|
+
name, url, = line.strip.split(/\s+/)
|
|
78
|
+
remotes[name] = url if url
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
remotes.find do |_name, url|
|
|
82
|
+
Ace::Git::Atoms::ServerUrl.match?(url, head_url)
|
|
83
|
+
end&.first
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_fetch_head
|
|
87
|
+
result = Ace::Git::Worktree::Atoms::GitCommand.execute(
|
|
88
|
+
"rev-parse", "--verify", "FETCH_HEAD^{commit}", timeout: 10
|
|
89
|
+
)
|
|
90
|
+
result[:success] ? result[:output].to_s.strip : nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def error_result(message)
|
|
94
|
+
{success: false, local_ref: nil, sha: nil, remote_tracking: nil, error: message}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Worktree
|
|
8
|
+
module Molecules
|
|
9
|
+
# Creates draft pull requests for task worktrees through the
|
|
10
|
+
# forge-neutral provider contract.
|
|
11
|
+
#
|
|
12
|
+
# The caller must supply the exact pushed head SHA (`expected_head`);
|
|
13
|
+
# creation or reuse is proven against that SHA by the provider, never
|
|
14
|
+
# inferred from branch names. Server selection resolves lazily inside
|
|
15
|
+
# the lifecycle organism, so local-only task runs never touch a forge.
|
|
16
|
+
class PullRequestCreator
|
|
17
|
+
# @param server_name [String, nil] explicit server selection
|
|
18
|
+
# @param use_default [Boolean] resolve the configured default server
|
|
19
|
+
# @param remote_name [String, nil] remote used for fallback resolution
|
|
20
|
+
# @param timeout [Integer, nil] provider timeout
|
|
21
|
+
# @param runner [Proc, nil] injectable provider runner (tests)
|
|
22
|
+
def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
|
|
23
|
+
@selection = {
|
|
24
|
+
server_name: server_name, use_default: use_default, remote_name: remote_name
|
|
25
|
+
}
|
|
26
|
+
@timeout = timeout
|
|
27
|
+
@runner = runner
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Create (or reconcile to) a draft PR for a pushed branch.
|
|
31
|
+
#
|
|
32
|
+
# @param branch [String] pushed head branch
|
|
33
|
+
# @param base [String] base branch/ref
|
|
34
|
+
# @param title [String] PR title
|
|
35
|
+
# @param expected_head [String] exact SHA the pushed branch resolves to
|
|
36
|
+
# @param head_repository_url [String, nil] source repository URL
|
|
37
|
+
# (defaults to the selected server's repository)
|
|
38
|
+
# @param body [String, nil] PR body text
|
|
39
|
+
# @return [Hash] {success:, pr_number:, pr_url:, existing:, head_sha:, error:}
|
|
40
|
+
def create_draft(branch:, base:, title:, expected_head:, head_repository_url: nil, body: nil)
|
|
41
|
+
lifecycle = Ace::Git::Organisms::PullRequestLifecycle.new(**@selection.merge(timeout: @timeout, runner: @runner))
|
|
42
|
+
receipt = lifecycle.create(
|
|
43
|
+
head_ref: branch,
|
|
44
|
+
head_repository_url: head_repository_url,
|
|
45
|
+
base_ref: base,
|
|
46
|
+
expected_head: expected_head,
|
|
47
|
+
title: title,
|
|
48
|
+
body: body,
|
|
49
|
+
draft: true
|
|
50
|
+
)
|
|
51
|
+
pr = receipt.pull_request
|
|
52
|
+
{
|
|
53
|
+
success: true,
|
|
54
|
+
pr_number: pr.number,
|
|
55
|
+
pr_url: pr.url,
|
|
56
|
+
existing: receipt.idempotency == :existing,
|
|
57
|
+
head_sha: pr.head_sha,
|
|
58
|
+
error: nil
|
|
59
|
+
}
|
|
60
|
+
rescue Ace::Git::Error => e
|
|
61
|
+
{success: false, pr_number: nil, pr_url: nil, existing: false, head_sha: nil, error: e.message}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Worktree
|
|
8
|
+
module Molecules
|
|
9
|
+
# Resolves normalized pull request evidence for worktree consumers.
|
|
10
|
+
#
|
|
11
|
+
# The server is selected once (explicit `--server`, `--default-server`,
|
|
12
|
+
# or remote resolution) and the PR is fetched through the registered
|
|
13
|
+
# provider contract. Local-only callers never construct this molecule,
|
|
14
|
+
# so no forge resolution happens without a PR-aware request.
|
|
15
|
+
class PullRequestEvidenceResolver
|
|
16
|
+
# @param server_name [String, nil] explicit server selection
|
|
17
|
+
# @param use_default [Boolean] resolve the configured default server
|
|
18
|
+
# @param remote_name [String, nil] git remote for fallback resolution
|
|
19
|
+
# @param timeout [Integer, nil] provider timeout
|
|
20
|
+
# @param runner [Proc, nil] injectable provider runner (tests)
|
|
21
|
+
def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
|
|
22
|
+
@selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
|
|
23
|
+
@timeout = timeout
|
|
24
|
+
@runner = runner
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Fetch exact PR evidence with source/base provenance.
|
|
28
|
+
#
|
|
29
|
+
# @param number [Integer, String] pull request number
|
|
30
|
+
# @return [Hash] {server: ResolvedServer, evidence: ProviderPullRequest}
|
|
31
|
+
def resolve(number)
|
|
32
|
+
server = Ace::Git::ServerRegistry.resolve_for(**@selection)
|
|
33
|
+
provider = Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
|
|
34
|
+
{server: server, evidence: provider.pull_request(number: number)}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|