ace-git-worktree 0.23.0 → 0.24.1
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 +58 -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 +67 -154
- data/lib/ace/git/worktree/molecules/cleanup_pr_resolver.rb +193 -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 +5 -17
- data/lib/ace/git/worktree/molecules/pr_creator.rb +0 -253
|
@@ -2,117 +2,226 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "open3"
|
|
5
|
-
require "ace/git
|
|
6
|
-
require "ace/git/github/pr_fetcher"
|
|
5
|
+
require "ace/git"
|
|
7
6
|
|
|
8
7
|
module Ace
|
|
9
8
|
module Git
|
|
10
9
|
module Worktree
|
|
11
10
|
module Molecules
|
|
12
|
-
# 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. Merge proof additionally requires the
|
|
18
|
+
# evidence's provenance (server, repository, branch, base) to agree
|
|
19
|
+
# with the resolved selection; incomplete or conflicting provenance
|
|
20
|
+
# retains. Local ancestry proof is handled by the reporter before
|
|
21
|
+
# this resolver is consulted.
|
|
13
22
|
class CleanupPrResolver
|
|
14
|
-
|
|
23
|
+
# @param target [String] base ref the PR must have targeted
|
|
24
|
+
# @param target_sha [String] commit SHA of the target
|
|
25
|
+
# @param offline [Boolean] skip provider evidence entirely
|
|
26
|
+
# @param server_name [String, nil] explicit server selection
|
|
27
|
+
# @param use_default [Boolean] resolve the configured default server
|
|
28
|
+
# @param remote_name [String, nil] remote used for fallback resolution
|
|
29
|
+
# @param timeout [Integer, nil] provider timeout
|
|
30
|
+
# @param runner [Proc, nil] injectable provider runner (tests)
|
|
31
|
+
def initialize(target:, target_sha:, offline: false, server_name: nil, use_default: false,
|
|
32
|
+
remote_name: nil, timeout: nil, runner: nil)
|
|
15
33
|
@target = target
|
|
16
34
|
@target_sha = target_sha
|
|
17
35
|
@offline = offline
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@
|
|
21
|
-
Ace::Git::Github::PrFetcher.authenticated?
|
|
36
|
+
@selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@runner = runner
|
|
22
39
|
end
|
|
23
40
|
|
|
24
|
-
#
|
|
41
|
+
# @return [Ace::Git::ResolvedServer, nil] the server resolved for
|
|
42
|
+
# provider evidence; nil when unresolvable (local proof only)
|
|
43
|
+
def resolved_server
|
|
44
|
+
resolve_provider
|
|
45
|
+
@resolved_server
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Classify a candidate branch with provider PR evidence.
|
|
25
49
|
#
|
|
26
|
-
# @param branch [String]
|
|
27
|
-
# @param candidate_sha [String]
|
|
28
|
-
# @return [Hash]
|
|
50
|
+
# @param branch [String] the branch name (e.g. "feature")
|
|
51
|
+
# @param candidate_sha [String] the commit SHA of the candidate
|
|
52
|
+
# @return [Hash] proof classification; :status is one of
|
|
53
|
+
# Ace::Git::CLEANUP_PROOF_STATUSES
|
|
29
54
|
def classify(branch, candidate_sha)
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
# Find a PR that targets the configured base and is actually reachable
|
|
36
|
-
valid_pr = prs.find do |pr|
|
|
37
|
-
pr["baseRefName"] == @target &&
|
|
38
|
-
pr["state"] == "MERGED" &&
|
|
39
|
-
pr["mergeCommit"] &&
|
|
40
|
-
pr["mergeCommit"]["oid"] &&
|
|
41
|
-
ancestor?(pr["mergeCommit"]["oid"], @target_sha)
|
|
42
|
-
end
|
|
55
|
+
return unproven_result(:no_pr, "provider_disabled") if @offline
|
|
56
|
+
|
|
57
|
+
evidence = fetch_merged_evidence(branch)
|
|
58
|
+
return failure_result(evidence) if evidence.is_a?(Hash) # transport failure marker
|
|
43
59
|
|
|
44
|
-
return
|
|
45
|
-
return unproven_result
|
|
60
|
+
return unproven_result(:no_pr) unless evidence
|
|
61
|
+
return unproven_result(:open) if evidence.state == :open
|
|
62
|
+
return unproven_result(:closed_unmerged) if evidence.state == :closed
|
|
63
|
+
return unproven_result(:merged, "state_unavailable") unless evidence.state == :merged
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
problem = provenance_problem(evidence, branch)
|
|
66
|
+
return unproven_result(:merged, problem) if problem
|
|
67
|
+
return unproven_result(:merged, "merge_commit_unavailable") unless evidence.merge_commit_sha
|
|
68
|
+
return unproven_result(:merged, "merge_commit_unreachable") unless ancestor?(evidence.merge_commit_sha, @target_sha)
|
|
69
|
+
|
|
70
|
+
merge_commit_sha = evidence.merge_commit_sha
|
|
71
|
+
pr_head_sha = evidence.head_sha
|
|
49
72
|
|
|
50
73
|
if candidate_sha == pr_head_sha
|
|
51
|
-
|
|
52
|
-
proof: "exact_merged_pr_head",
|
|
53
|
-
pr: valid_pr["number"],
|
|
54
|
-
candidate_head: candidate_sha,
|
|
55
|
-
merged_head: pr_head_sha,
|
|
56
|
-
merge_commit: merge_commit_sha,
|
|
57
|
-
target_reachable: true,
|
|
58
|
-
path_type_mode_match: true,
|
|
59
|
-
provider_status: "available",
|
|
60
|
-
action: "remove"
|
|
61
|
-
}
|
|
74
|
+
proof_result(evidence, "exact_merged_pr_head", candidate_sha, pr_head_sha, merge_commit_sha)
|
|
62
75
|
elsif patch_equivalent?(candidate_sha, merge_commit_sha)
|
|
63
|
-
|
|
64
|
-
proof: "stable_patch_equivalence",
|
|
65
|
-
pr: valid_pr["number"],
|
|
66
|
-
candidate_head: candidate_sha,
|
|
67
|
-
merged_head: pr_head_sha,
|
|
68
|
-
merge_commit: merge_commit_sha,
|
|
69
|
-
target_reachable: true,
|
|
70
|
-
path_type_mode_match: true,
|
|
71
|
-
provider_status: "available",
|
|
72
|
-
action: "remove"
|
|
73
|
-
}
|
|
76
|
+
proof_result(evidence, "stable_patch_equivalence", candidate_sha, pr_head_sha, merge_commit_sha)
|
|
74
77
|
else
|
|
75
78
|
{
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
status: :merged,
|
|
80
|
+
proof: nil,
|
|
81
|
+
pr: evidence.number,
|
|
82
|
+
pr_url: evidence.url,
|
|
78
83
|
candidate_head: candidate_sha,
|
|
79
84
|
merged_head: pr_head_sha,
|
|
80
85
|
merge_commit: merge_commit_sha,
|
|
81
86
|
target_reachable: true,
|
|
82
|
-
path_type_mode_match: false,
|
|
83
87
|
provider_status: "available",
|
|
84
88
|
action: "retain",
|
|
85
89
|
retention_reason: "patch_mismatch"
|
|
86
90
|
}
|
|
87
91
|
end
|
|
88
|
-
rescue Ace::Git::ProviderCliMissingError, Ace::Git::ProviderAuthenticationError, Ace::Git::TimeoutError
|
|
89
|
-
# Provider failure degradation
|
|
90
|
-
offline_result("unavailable")
|
|
91
92
|
end
|
|
92
93
|
|
|
93
94
|
private
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
96
|
+
# Fetch the merged PR evidence for a branch, or a failure marker
|
|
97
|
+
# hash {:failure_status => ...} on classified transport failures.
|
|
98
|
+
def fetch_merged_evidence(branch)
|
|
99
|
+
provider = resolve_provider
|
|
100
|
+
return {failure_status: :offline} unless provider
|
|
101
|
+
|
|
102
|
+
provider.pull_request_for_branch(branch: branch)
|
|
103
|
+
rescue Ace::Git::ProviderAuthenticationError
|
|
104
|
+
{failure_status: :authentication_error}
|
|
105
|
+
rescue Ace::Git::ProviderMalformedOutputError
|
|
106
|
+
{failure_status: :malformed}
|
|
107
|
+
rescue Ace::Git::Error
|
|
108
|
+
{failure_status: :offline}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Resolve the provider lazily, once per resolver. Unresolvable
|
|
112
|
+
# server selection (no config, ambiguous remote, missing provider)
|
|
113
|
+
# means no provider evidence is available.
|
|
114
|
+
def resolve_provider
|
|
115
|
+
return @provider if defined?(@provider)
|
|
116
|
+
|
|
117
|
+
@provider = begin
|
|
118
|
+
server = Ace::Git::ServerRegistry.resolve_for(**@selection)
|
|
119
|
+
@resolved_server = server
|
|
120
|
+
Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
|
|
121
|
+
rescue Ace::Git::Error
|
|
122
|
+
@resolved_server = nil
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Merge proof may only remove a candidate whose evidence provably
|
|
128
|
+
# belongs to the resolved server and the candidate's branch/base.
|
|
129
|
+
# PR and branch identities are not globally unique: incomplete
|
|
130
|
+
# provenance cannot be verified and conflicting provenance means
|
|
131
|
+
# the evidence was routed from another repository — both retain.
|
|
132
|
+
# @return [String, nil] retention reason, or nil when provenance agrees
|
|
133
|
+
def provenance_problem(evidence, branch)
|
|
134
|
+
server = resolved_server
|
|
135
|
+
unless server && evidence.server_name && evidence.base_repository_url &&
|
|
136
|
+
evidence.head_ref && evidence.base_ref
|
|
137
|
+
return "provenance_incomplete"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
return "provenance_conflict" unless evidence.server_name == server.name
|
|
141
|
+
return "provenance_conflict" unless Ace::Git::Atoms::ServerUrl.match?(evidence.base_repository_url, server.url)
|
|
142
|
+
return "provenance_conflict" unless evidence.head_ref == branch
|
|
143
|
+
return "provenance_conflict" unless evidence.base_ref == target_branch
|
|
144
|
+
|
|
111
145
|
nil
|
|
112
146
|
end
|
|
113
147
|
|
|
148
|
+
# The branch the PR must have targeted. Targets arrive as bare
|
|
149
|
+
# branch names, remote-tracking refs ("origin/main",
|
|
150
|
+
# "refs/remotes/origin/main"), or local refs ("refs/heads/main");
|
|
151
|
+
# all of those forms name the same branch the PR base reports. A
|
|
152
|
+
# path segment only counts as a remote prefix when it names a
|
|
153
|
+
# configured git remote, so slashed branch names stay strict.
|
|
154
|
+
def target_branch
|
|
155
|
+
ref = @target.to_s
|
|
156
|
+
ref = ref.sub(%r{\Arefs/remotes/([^/]+)/}) do
|
|
157
|
+
git_remotes.include?(Regexp.last_match(1)) ? "" : Regexp.last_match(0)
|
|
158
|
+
end
|
|
159
|
+
ref = ref.sub(%r{\Arefs/heads/}, "")
|
|
160
|
+
if ref.include?("/")
|
|
161
|
+
prefix, rest = ref.split("/", 2)
|
|
162
|
+
ref = rest if git_remotes.include?(prefix)
|
|
163
|
+
end
|
|
164
|
+
ref
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def git_remotes
|
|
168
|
+
out, _status = Open3.capture2("git", "remote")
|
|
169
|
+
out.to_s.lines.map(&:strip).reject(&:empty?)
|
|
170
|
+
rescue StandardError
|
|
171
|
+
[]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def proof_result(evidence, proof, candidate_sha, pr_head_sha, merge_commit_sha)
|
|
175
|
+
{
|
|
176
|
+
status: :merged,
|
|
177
|
+
proof: proof,
|
|
178
|
+
pr: evidence.number,
|
|
179
|
+
pr_url: evidence.url,
|
|
180
|
+
candidate_head: candidate_sha,
|
|
181
|
+
merged_head: pr_head_sha,
|
|
182
|
+
merge_commit: merge_commit_sha,
|
|
183
|
+
target_reachable: true,
|
|
184
|
+
provider_status: "available",
|
|
185
|
+
action: "remove",
|
|
186
|
+
retention_reason: nil
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def unproven_result(status, reason = "ancestry_unproven")
|
|
191
|
+
{
|
|
192
|
+
status: status,
|
|
193
|
+
proof: nil,
|
|
194
|
+
pr: nil,
|
|
195
|
+
pr_url: nil,
|
|
196
|
+
candidate_head: nil,
|
|
197
|
+
merged_head: nil,
|
|
198
|
+
merge_commit: nil,
|
|
199
|
+
target_reachable: "unknown",
|
|
200
|
+
provider_status: "available",
|
|
201
|
+
action: "retain",
|
|
202
|
+
retention_reason: reason
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def failure_result(marker)
|
|
207
|
+
status = marker[:failure_status]
|
|
208
|
+
{
|
|
209
|
+
status: status,
|
|
210
|
+
proof: nil,
|
|
211
|
+
pr: nil,
|
|
212
|
+
pr_url: nil,
|
|
213
|
+
candidate_head: nil,
|
|
214
|
+
merged_head: nil,
|
|
215
|
+
merge_commit: nil,
|
|
216
|
+
target_reachable: "unknown",
|
|
217
|
+
provider_status: status.to_s,
|
|
218
|
+
action: "retain",
|
|
219
|
+
retention_reason: "provider_evidence_unavailable"
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
114
223
|
def ancestor?(candidate, target)
|
|
115
|
-
|
|
224
|
+
_out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
|
|
116
225
|
status.success?
|
|
117
226
|
end
|
|
118
227
|
|
|
@@ -122,7 +231,7 @@ module Ace
|
|
|
122
231
|
base, status = Open3.capture2("git", "merge-base", candidate_sha, @target_sha)
|
|
123
232
|
return false unless status.success?
|
|
124
233
|
base = base.strip
|
|
125
|
-
|
|
234
|
+
|
|
126
235
|
# Candidate inventory: path, type, mode changes
|
|
127
236
|
cand_inv = tree_diff_inventory(base, candidate_sha)
|
|
128
237
|
return false unless cand_inv
|
|
@@ -136,9 +245,8 @@ module Ace
|
|
|
136
245
|
end
|
|
137
246
|
|
|
138
247
|
def tree_diff_inventory(tree_a, tree_b)
|
|
139
|
-
# git diff-tree -r
|
|
140
|
-
#
|
|
141
|
-
# :100644 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0000000000000000000000000000000000000000 M file
|
|
248
|
+
# git diff-tree -r raw mode carries exact mode and type information
|
|
249
|
+
# :100644 100644 e69de29... 000000... M file
|
|
142
250
|
out, status = Open3.capture2("git", "diff-tree", "-r", "--no-commit-id", tree_a, tree_b)
|
|
143
251
|
return nil unless status.success?
|
|
144
252
|
|
|
@@ -146,66 +254,19 @@ module Ace
|
|
|
146
254
|
out.each_line do |line|
|
|
147
255
|
# Format: :src_mode dst_mode src_sha dst_sha status\tpath
|
|
148
256
|
parts = line.strip.split("\t", 2)
|
|
257
|
+
next if parts.length < 2
|
|
258
|
+
|
|
149
259
|
meta = parts[0].split(" ")
|
|
150
260
|
path = parts[1]
|
|
151
|
-
|
|
152
|
-
dst_mode = meta[1]
|
|
153
|
-
dst_sha = meta[3]
|
|
154
|
-
status_char = meta[4]
|
|
155
|
-
|
|
261
|
+
|
|
156
262
|
inventory[path] = {
|
|
157
|
-
mode:
|
|
158
|
-
sha:
|
|
159
|
-
status:
|
|
263
|
+
mode: meta[1],
|
|
264
|
+
sha: meta[3],
|
|
265
|
+
status: meta[4][0] # handle R100 etc by taking first char
|
|
160
266
|
}
|
|
161
267
|
end
|
|
162
|
-
|
|
163
|
-
inventory
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def offline_result(status = "offline")
|
|
167
|
-
{
|
|
168
|
-
proof: "none",
|
|
169
|
-
pr: nil,
|
|
170
|
-
candidate_head: nil,
|
|
171
|
-
merged_head: nil,
|
|
172
|
-
merge_commit: nil,
|
|
173
|
-
target_reachable: "unknown",
|
|
174
|
-
path_type_mode_match: "unknown",
|
|
175
|
-
provider_status: status,
|
|
176
|
-
action: "retain",
|
|
177
|
-
retention_reason: "ancestry_unproven"
|
|
178
|
-
}
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def unproven_result
|
|
182
|
-
{
|
|
183
|
-
proof: "none",
|
|
184
|
-
pr: nil,
|
|
185
|
-
candidate_head: nil,
|
|
186
|
-
merged_head: nil,
|
|
187
|
-
merge_commit: nil,
|
|
188
|
-
target_reachable: "unknown",
|
|
189
|
-
path_type_mode_match: "unknown",
|
|
190
|
-
provider_status: "available",
|
|
191
|
-
action: "retain",
|
|
192
|
-
retention_reason: "ancestry_unproven"
|
|
193
|
-
}
|
|
194
|
-
end
|
|
195
268
|
|
|
196
|
-
|
|
197
|
-
{
|
|
198
|
-
proof: "none",
|
|
199
|
-
pr: nil,
|
|
200
|
-
candidate_head: nil,
|
|
201
|
-
merged_head: nil,
|
|
202
|
-
merge_commit: nil,
|
|
203
|
-
target_reachable: "unknown",
|
|
204
|
-
path_type_mode_match: "unknown",
|
|
205
|
-
provider_status: "ambiguous",
|
|
206
|
-
action: "retain",
|
|
207
|
-
retention_reason: "ambiguous_prs"
|
|
208
|
-
}
|
|
269
|
+
inventory
|
|
209
270
|
end
|
|
210
271
|
end
|
|
211
272
|
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
|