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.
@@ -2,117 +2,226 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
- require "ace/git/github"
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 to prove safe squash-merge cleanup.
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
- def initialize(target:, target_sha:, offline: false)
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
- # Cache gh availability to avoid repeated checks
20
- @gh_available = Ace::Git::Github::PrFetcher.installed? &&
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
- # Classify a candidate branch with GitHub PR evidence.
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] The branch name (e.g. "feature")
27
- # @param candidate_sha [String] The commit SHA of the candidate
28
- # @return [Hash] Proof classification result
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 offline_result unless @gh_available && !@offline
31
-
32
- prs = fetch_merged_prs_for_branch(branch)
33
- return unproven_result if prs.nil? || prs.empty?
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 ambiguous_result(prs) if prs.length > 1 && !valid_pr
45
- return unproven_result unless valid_pr
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
- merge_commit_sha = valid_pr["mergeCommit"]["oid"]
48
- pr_head_sha = valid_pr["headRefOid"]
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
- proof: "none",
77
- pr: valid_pr["number"],
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
- def fetch_merged_prs_for_branch(branch)
96
- # Use `gh pr list --head <branch> --state merged` to find the exact PR
97
- cmd = [
98
- "gh", "pr", "list", "--state", "merged", "--head", branch,
99
- "--json", "number,state,headRefOid,mergeCommit,baseRefName"
100
- ]
101
-
102
- # Using Open3 directly to avoid circular dependencies if we don't want to augment ace-git too heavily for just this
103
- # but we can rely on LC_ALL=C for safety.
104
- env = {"LC_ALL" => "C"}
105
- out, err, status = Open3.capture3(env, *cmd)
106
-
107
- return nil unless status.success?
108
-
109
- JSON.parse(out)
110
- rescue JSON::ParserError, Errno::ENOENT
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
- out, status = Open3.capture2("git", "merge-base", "--is-ancestor", candidate, target)
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 --name-status or raw mode
140
- # using raw mode to get exact mode and type
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: dst_mode,
158
- sha: dst_sha,
159
- status: status_char[0] # handle R100 etc by taking first char
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
- def ambiguous_result(prs)
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
- def initialize(target:, remote: "origin", offline: false)
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(target: @target, target_sha: target_sha, offline: @offline)
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
- # Compute canonical digest
60
- plan_digest = compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha)
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 GitHub PR evidence
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 GitHub PR evidence
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
- def compute_plan_digest(worktrees, local_refs, remote_refs, actions, target_sha)
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
- 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]] },
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