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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/docs/usage.md +37 -3
  4. data/handbook/skills/as-git-worktree-cleanup/SKILL.md +20 -0
  5. data/handbook/workflow-instructions/git/worktree-cleanup.wf.md +63 -0
  6. data/handbook/workflow-instructions/git/worktree-create.wf.md +6 -0
  7. data/lib/ace/git/worktree/cli/commands/bootstrap.rb +43 -0
  8. data/lib/ace/git/worktree/cli/commands/cleanup.rb +45 -0
  9. data/lib/ace/git/worktree/cli/commands/config.rb +27 -4
  10. data/lib/ace/git/worktree/cli/commands/create.rb +1 -0
  11. data/lib/ace/git/worktree/cli.rb +6 -0
  12. data/lib/ace/git/worktree/commands/bootstrap_command.rb +159 -0
  13. data/lib/ace/git/worktree/commands/cleanup_command.rb +307 -0
  14. data/lib/ace/git/worktree/commands/config_command.rb +354 -181
  15. data/lib/ace/git/worktree/commands/create_command.rb +5 -0
  16. data/lib/ace/git/worktree/models/worktree_config.rb +33 -3
  17. data/lib/ace/git/worktree/molecules/bootstrap_executor.rb +99 -0
  18. data/lib/ace/git/worktree/molecules/cleanup_applier.rb +225 -0
  19. data/lib/ace/git/worktree/molecules/cleanup_pr_resolver.rb +213 -0
  20. data/lib/ace/git/worktree/molecules/cleanup_reporter.rb +396 -0
  21. data/lib/ace/git/worktree/molecules/config_loader.rb +1 -1
  22. data/lib/ace/git/worktree/molecules/task_committer.rb +55 -8
  23. data/lib/ace/git/worktree/molecules/toolchain_truster.rb +112 -0
  24. data/lib/ace/git/worktree/molecules/worktree_lister.rb +17 -1
  25. data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +159 -10
  26. data/lib/ace/git/worktree/organisms/worktree_manager.rb +4 -2
  27. data/lib/ace/git/worktree/version.rb +1 -1
  28. 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
- CONFIG_NAMESPACE.reduce(config_hash) do |current, key|
401
- current&.dig(key) || {}
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