carson 2.24.0 → 2.26.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/.github/workflows/carson_policy.yml +5 -14
- data/API.md +6 -36
- data/MANUAL.md +10 -38
- data/README.md +9 -22
- data/RELEASE.md +24 -0
- data/SKILL.md +5 -7
- data/VERSION +1 -1
- data/lib/carson/cli.rb +1 -59
- data/lib/carson/config.rb +25 -50
- data/lib/carson/runtime/audit.rb +2 -61
- data/lib/carson/runtime/govern.rb +7 -36
- data/lib/carson/runtime/local/hooks.rb +142 -0
- data/lib/carson/runtime/local/onboard.rb +356 -0
- data/lib/carson/runtime/local/prune.rb +292 -0
- data/lib/carson/runtime/local/sync.rb +93 -0
- data/lib/carson/runtime/local/template.rb +347 -0
- data/lib/carson/runtime/local.rb +6 -1225
- data/lib/carson/runtime/review/gate_support.rb +1 -1
- data/lib/carson/runtime/review/sweep_support.rb +1 -1
- data/lib/carson/runtime/review/utility.rb +2 -2
- data/lib/carson/runtime/setup.rb +8 -2
- data/lib/carson/runtime.rb +0 -1
- data/templates/.github/carson.md +0 -1
- metadata +6 -2
- data/lib/carson/runtime/lint.rb +0 -154
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
module Carson
|
|
2
|
+
class Runtime
|
|
3
|
+
module Local
|
|
4
|
+
# One-command onboarding for new repositories: detect remote, install hooks,
|
|
5
|
+
# apply templates, and run initial audit.
|
|
6
|
+
def onboard!
|
|
7
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
8
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
9
|
+
|
|
10
|
+
unless inside_git_work_tree?
|
|
11
|
+
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
12
|
+
return EXIT_ERROR
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
repo_name = File.basename( repo_root )
|
|
16
|
+
puts_line ""
|
|
17
|
+
puts_line "Onboarding #{repo_name}..."
|
|
18
|
+
|
|
19
|
+
if !global_config_exists? || !git_remote_exists?( remote_name: config.git_remote )
|
|
20
|
+
if self.in.respond_to?( :tty? ) && self.in.tty?
|
|
21
|
+
setup_status = setup!
|
|
22
|
+
return setup_status unless setup_status == EXIT_OK
|
|
23
|
+
else
|
|
24
|
+
silent_setup!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
onboard_apply!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Re-applies hooks, templates, and audit after upgrading Carson.
|
|
32
|
+
def refresh!
|
|
33
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
34
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
35
|
+
|
|
36
|
+
unless inside_git_work_tree?
|
|
37
|
+
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
38
|
+
return EXIT_ERROR
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if verbose?
|
|
42
|
+
puts_verbose ""
|
|
43
|
+
puts_verbose "[Refresh]"
|
|
44
|
+
hook_status = prepare!
|
|
45
|
+
return hook_status unless hook_status == EXIT_OK
|
|
46
|
+
|
|
47
|
+
drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
48
|
+
template_status = template_apply!
|
|
49
|
+
return template_status unless template_status == EXIT_OK
|
|
50
|
+
|
|
51
|
+
@template_sync_result = template_propagate!( drift_count: drift_count )
|
|
52
|
+
|
|
53
|
+
audit_status = audit!
|
|
54
|
+
if audit_status == EXIT_OK
|
|
55
|
+
puts_line "OK: Carson refresh completed for #{repo_root}."
|
|
56
|
+
elsif audit_status == EXIT_BLOCK
|
|
57
|
+
puts_line "BLOCK: Carson refresh completed with policy blocks; resolve and rerun carson audit."
|
|
58
|
+
end
|
|
59
|
+
return audit_status
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
puts_line "Refresh"
|
|
63
|
+
hook_status = with_captured_output { prepare! }
|
|
64
|
+
return hook_status unless hook_status == EXIT_OK
|
|
65
|
+
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
66
|
+
|
|
67
|
+
template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
68
|
+
template_status = with_captured_output { template_apply! }
|
|
69
|
+
return template_status unless template_status == EXIT_OK
|
|
70
|
+
if template_drift_count.positive?
|
|
71
|
+
puts_line "Templates applied (#{template_drift_count} updated)."
|
|
72
|
+
else
|
|
73
|
+
puts_line "Templates in sync."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@template_sync_result = template_propagate!( drift_count: template_drift_count )
|
|
77
|
+
|
|
78
|
+
audit_status = audit!
|
|
79
|
+
puts_line "Refresh complete."
|
|
80
|
+
audit_status
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Re-applies hooks, templates, and audit across all governed repositories.
|
|
84
|
+
def refresh_all!
|
|
85
|
+
repos = config.govern_repos
|
|
86
|
+
if repos.empty?
|
|
87
|
+
puts_line "No governed repositories configured."
|
|
88
|
+
puts_line " Run carson onboard in each repo to register."
|
|
89
|
+
return EXIT_ERROR
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
puts_line ""
|
|
93
|
+
puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
|
|
94
|
+
refreshed = 0
|
|
95
|
+
failed = 0
|
|
96
|
+
|
|
97
|
+
repos.each do |repo_path|
|
|
98
|
+
repo_name = File.basename( repo_path )
|
|
99
|
+
unless Dir.exist?( repo_path )
|
|
100
|
+
puts_line "#{repo_name}: FAIL (path not found)"
|
|
101
|
+
failed += 1
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
|
|
106
|
+
if status == EXIT_ERROR
|
|
107
|
+
failed += 1
|
|
108
|
+
else
|
|
109
|
+
refreshed += 1
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
puts_line ""
|
|
114
|
+
puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
|
|
115
|
+
failed.zero? ? EXIT_OK : EXIT_ERROR
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
|
|
119
|
+
def offboard!
|
|
120
|
+
puts_verbose ""
|
|
121
|
+
puts_verbose "[Offboard]"
|
|
122
|
+
unless inside_git_work_tree?
|
|
123
|
+
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
124
|
+
return EXIT_ERROR
|
|
125
|
+
end
|
|
126
|
+
if self.in.respond_to?( :tty? ) && self.in.tty?
|
|
127
|
+
puts_line ""
|
|
128
|
+
puts_line "This will remove Carson hooks, managed .github/ files,"
|
|
129
|
+
puts_line "and deregister this repository from portfolio governance."
|
|
130
|
+
puts_line "Continue?"
|
|
131
|
+
unless prompt_yes_no( default: false )
|
|
132
|
+
puts_line "Offboard cancelled."
|
|
133
|
+
return EXIT_OK
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
hooks_status = disable_carson_hooks_path!
|
|
138
|
+
return hooks_status unless hooks_status == EXIT_OK
|
|
139
|
+
|
|
140
|
+
removed_count = 0
|
|
141
|
+
missing_count = 0
|
|
142
|
+
offboard_cleanup_targets.each do |relative|
|
|
143
|
+
absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" )
|
|
144
|
+
if File.exist?( absolute )
|
|
145
|
+
FileUtils.rm_rf( absolute )
|
|
146
|
+
puts_verbose "removed_path: #{relative}"
|
|
147
|
+
removed_count += 1
|
|
148
|
+
else
|
|
149
|
+
puts_verbose "skip_missing_path: #{relative}"
|
|
150
|
+
missing_count += 1
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
remove_empty_offboard_directories!
|
|
154
|
+
remove_govern_repo!( repo_path: File.expand_path( repo_root ) )
|
|
155
|
+
puts_verbose "govern_deregistered: #{File.expand_path( repo_root )}"
|
|
156
|
+
puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
|
|
157
|
+
if verbose?
|
|
158
|
+
puts_line "OK: Carson offboard completed for #{repo_root}."
|
|
159
|
+
else
|
|
160
|
+
puts_line "Removed #{removed_count} file#{plural_suffix( count: removed_count )}. Offboard complete."
|
|
161
|
+
end
|
|
162
|
+
puts_line ""
|
|
163
|
+
puts_line "Next: commit the removals and push to finalise offboarding."
|
|
164
|
+
EXIT_OK
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# Concise onboard orchestration: hooks, templates, remote, audit, guidance.
|
|
170
|
+
def onboard_apply!
|
|
171
|
+
hook_status = with_captured_output { prepare! }
|
|
172
|
+
return hook_status unless hook_status == EXIT_OK
|
|
173
|
+
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
174
|
+
|
|
175
|
+
template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
176
|
+
template_status = with_captured_output { template_apply! }
|
|
177
|
+
return template_status unless template_status == EXIT_OK
|
|
178
|
+
if template_drift_count.positive?
|
|
179
|
+
puts_line "Templates synced (#{template_drift_count} file#{plural_suffix( count: template_drift_count )} updated)."
|
|
180
|
+
else
|
|
181
|
+
puts_line "Templates in sync."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
onboard_report_remote!
|
|
185
|
+
audit_status = onboard_run_audit!
|
|
186
|
+
|
|
187
|
+
puts_line ""
|
|
188
|
+
puts_line "Carson at your service."
|
|
189
|
+
|
|
190
|
+
if self.in.respond_to?( :tty? ) && self.in.tty?
|
|
191
|
+
prompt_govern_registration!
|
|
192
|
+
else
|
|
193
|
+
puts_line "To register for portfolio governance: carson onboard (in a TTY)"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
puts_line ""
|
|
197
|
+
puts_line "Your repository is set up. Carson has placed files in your"
|
|
198
|
+
puts_line "project's .github/ directory — pull request templates,"
|
|
199
|
+
puts_line "guidelines for AI coding assistants, and any canonical"
|
|
200
|
+
puts_line "rules you've configured. Once pushed to GitHub, they'll"
|
|
201
|
+
puts_line "ensure every pull request follows a consistent standard"
|
|
202
|
+
puts_line "and all checks run automatically."
|
|
203
|
+
puts_line ""
|
|
204
|
+
puts_line "Before your first push, have a look through .github/ to"
|
|
205
|
+
puts_line "make sure everything is to your liking."
|
|
206
|
+
puts_line ""
|
|
207
|
+
puts_line "To adjust any setting: carson setup"
|
|
208
|
+
|
|
209
|
+
audit_status
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Friendly remote status for onboard output.
|
|
213
|
+
def onboard_report_remote!
|
|
214
|
+
if git_remote_exists?( remote_name: config.git_remote )
|
|
215
|
+
puts_line "Remote: #{config.git_remote} (connected)."
|
|
216
|
+
else
|
|
217
|
+
puts_line "Remote not configured yet — carson setup will walk you through it."
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Runs audit with captured output; reports summary instead of full detail.
|
|
222
|
+
def onboard_run_audit!
|
|
223
|
+
audit_error = nil
|
|
224
|
+
audit_status = with_captured_output { audit! }
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
audit_error = e
|
|
227
|
+
audit_status = EXIT_OK
|
|
228
|
+
ensure
|
|
229
|
+
return onboard_print_audit_result( status: audit_status, error: audit_error )
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def onboard_print_audit_result( status:, error: )
|
|
233
|
+
if error
|
|
234
|
+
if error.message.to_s.match?( /HEAD|rev-parse/ )
|
|
235
|
+
puts_line "No commits yet — run carson audit after your first commit."
|
|
236
|
+
else
|
|
237
|
+
puts_line "Audit skipped — run carson audit for details."
|
|
238
|
+
end
|
|
239
|
+
return EXIT_OK
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
if status == EXIT_BLOCK
|
|
243
|
+
puts_line "Some checks need attention — run carson audit for details."
|
|
244
|
+
end
|
|
245
|
+
status
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Verifies configured remote exists and logs status without mutating remotes.
|
|
249
|
+
def report_detected_remote!
|
|
250
|
+
if git_remote_exists?( remote_name: config.git_remote )
|
|
251
|
+
puts_verbose "remote_ok: #{config.git_remote}"
|
|
252
|
+
else
|
|
253
|
+
puts_line "WARN: remote '#{config.git_remote}' not found; run carson setup to configure."
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def refresh_sync_suffix( result: )
|
|
258
|
+
return "" if result.nil?
|
|
259
|
+
|
|
260
|
+
case result.fetch( :status )
|
|
261
|
+
when :pushed then " (templates pushed to #{result.fetch( :ref )})"
|
|
262
|
+
when :pr then " (PR: #{result.fetch( :pr_url )})"
|
|
263
|
+
else ""
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Refreshes a single governed repository using a scoped Runtime.
|
|
268
|
+
def refresh_single_repo( repo_path:, repo_name: )
|
|
269
|
+
if verbose?
|
|
270
|
+
rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err, verbose: true )
|
|
271
|
+
else
|
|
272
|
+
rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: StringIO.new, err: StringIO.new )
|
|
273
|
+
end
|
|
274
|
+
status = rt.refresh!
|
|
275
|
+
label = refresh_status_label( status: status )
|
|
276
|
+
sync_suffix = refresh_sync_suffix( result: rt.template_sync_result )
|
|
277
|
+
puts_line "#{repo_name}: #{label}#{sync_suffix}"
|
|
278
|
+
status
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
281
|
+
EXIT_ERROR
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def refresh_status_label( status: )
|
|
285
|
+
case status
|
|
286
|
+
when EXIT_OK then "OK"
|
|
287
|
+
when EXIT_BLOCK then "BLOCK"
|
|
288
|
+
else "FAIL"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def disable_carson_hooks_path!
|
|
293
|
+
configured = configured_hooks_path
|
|
294
|
+
if configured.nil?
|
|
295
|
+
puts_verbose "hooks_path: (unset)"
|
|
296
|
+
return EXIT_OK
|
|
297
|
+
end
|
|
298
|
+
puts_verbose "hooks_path: #{configured}"
|
|
299
|
+
configured_abs = File.expand_path( configured, repo_root )
|
|
300
|
+
unless carson_managed_hooks_path?( configured_abs: configured_abs )
|
|
301
|
+
puts_verbose "hooks_path_kept: #{configured} (not Carson-managed)"
|
|
302
|
+
return EXIT_OK
|
|
303
|
+
end
|
|
304
|
+
git_system!( "config", "--unset", "core.hooksPath" )
|
|
305
|
+
puts_verbose "hooks_path_unset: core.hooksPath"
|
|
306
|
+
EXIT_OK
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
puts_line "ERROR: unable to update core.hooksPath (#{e.message})"
|
|
309
|
+
EXIT_ERROR
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def carson_managed_hooks_path?( configured_abs: )
|
|
313
|
+
hooks_root = File.join( File.expand_path( config.hooks_path ), "" )
|
|
314
|
+
return true if configured_abs.start_with?( hooks_root )
|
|
315
|
+
|
|
316
|
+
carson_hook_files_match_templates?( hooks_path: configured_abs )
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def carson_hook_files_match_templates?( hooks_path: )
|
|
320
|
+
return false unless Dir.exist?( hooks_path )
|
|
321
|
+
config.managed_hooks.all? do |hook_name|
|
|
322
|
+
installed_path = File.join( hooks_path, hook_name )
|
|
323
|
+
template_path = hook_template_path( hook_name: hook_name )
|
|
324
|
+
next false unless File.file?( installed_path ) && File.file?( template_path )
|
|
325
|
+
|
|
326
|
+
installed_content = normalize_text( text: File.read( installed_path ) )
|
|
327
|
+
template_content = normalize_text( text: File.read( template_path ) )
|
|
328
|
+
installed_content == template_content
|
|
329
|
+
end
|
|
330
|
+
rescue StandardError
|
|
331
|
+
false
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def offboard_cleanup_targets
|
|
335
|
+
( config.template_managed_files + SUPERSEDED + [
|
|
336
|
+
".github/workflows/carson-governance.yml",
|
|
337
|
+
".github/workflows/carson_policy.yml",
|
|
338
|
+
".carson.yml",
|
|
339
|
+
"bin/carson",
|
|
340
|
+
".tools/carson"
|
|
341
|
+
] ).uniq
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def remove_empty_offboard_directories!
|
|
345
|
+
[ ".github/workflows", ".github", ".tools", "bin" ].each do |relative|
|
|
346
|
+
absolute = resolve_repo_path!( relative_path: relative, label: "offboard cleanup directory #{relative}" )
|
|
347
|
+
next unless Dir.exist?( absolute )
|
|
348
|
+
next unless Dir.empty?( absolute )
|
|
349
|
+
|
|
350
|
+
Dir.rmdir( absolute )
|
|
351
|
+
puts_verbose "removed_empty_dir: #{relative}"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
module Carson
|
|
2
|
+
class Runtime
|
|
3
|
+
module Local
|
|
4
|
+
# Removes stale local branches (gone upstream) and orphan branches (no tracking) with merged PR evidence.
|
|
5
|
+
def prune!
|
|
6
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
7
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
8
|
+
|
|
9
|
+
git_system!( "fetch", config.git_remote, "--prune" )
|
|
10
|
+
active_branch = current_branch
|
|
11
|
+
counters = { deleted: 0, skipped: 0 }
|
|
12
|
+
|
|
13
|
+
stale_branches = stale_local_branches
|
|
14
|
+
prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters )
|
|
15
|
+
|
|
16
|
+
orphan_branches = orphan_local_branches( active_branch: active_branch )
|
|
17
|
+
prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )
|
|
18
|
+
|
|
19
|
+
return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?
|
|
20
|
+
|
|
21
|
+
puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
|
|
22
|
+
unless verbose?
|
|
23
|
+
deleted_count = counters.fetch( :deleted )
|
|
24
|
+
if deleted_count.zero?
|
|
25
|
+
puts_line "No stale branches."
|
|
26
|
+
else
|
|
27
|
+
puts_line "Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
EXIT_OK
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def prune_no_stale_branches
|
|
36
|
+
if verbose?
|
|
37
|
+
puts_line "OK: no stale or orphan branches to prune."
|
|
38
|
+
else
|
|
39
|
+
puts_line "No stale branches."
|
|
40
|
+
end
|
|
41
|
+
EXIT_OK
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 } )
|
|
45
|
+
stale_branches.each do |entry|
|
|
46
|
+
outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
|
|
47
|
+
counters[ outcome ] += 1
|
|
48
|
+
end
|
|
49
|
+
counters
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def prune_stale_branch_entry( entry:, active_branch: )
|
|
53
|
+
branch = entry.fetch( :branch )
|
|
54
|
+
upstream = entry.fetch( :upstream )
|
|
55
|
+
return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
|
|
56
|
+
return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch
|
|
57
|
+
|
|
58
|
+
prune_delete_stale_branch( branch: branch, upstream: upstream )
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def prune_skip_stale_branch( type:, branch:, upstream: )
|
|
62
|
+
status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
|
|
63
|
+
puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
|
|
64
|
+
:skipped
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def prune_delete_stale_branch( branch:, upstream: )
|
|
68
|
+
stdout_text, stderr_text, success, = git_run( "branch", "-d", branch )
|
|
69
|
+
return prune_safe_delete_success( branch: branch, upstream: upstream, stdout_text: stdout_text ) if success
|
|
70
|
+
|
|
71
|
+
delete_error_text = normalise_branch_delete_error( error_text: stderr_text )
|
|
72
|
+
prune_force_delete_stale_branch(
|
|
73
|
+
branch: branch,
|
|
74
|
+
upstream: upstream,
|
|
75
|
+
delete_error_text: delete_error_text
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def prune_safe_delete_success( branch:, upstream:, stdout_text: )
|
|
80
|
+
out.print stdout_text if verbose? && !stdout_text.empty?
|
|
81
|
+
puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
|
|
82
|
+
:deleted
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
|
|
86
|
+
merged_pr, force_error = force_delete_evidence_for_stale_branch(
|
|
87
|
+
branch: branch,
|
|
88
|
+
delete_error_text: delete_error_text
|
|
89
|
+
)
|
|
90
|
+
return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?
|
|
91
|
+
|
|
92
|
+
force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
|
|
93
|
+
return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success
|
|
94
|
+
|
|
95
|
+
prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
|
|
99
|
+
out.print force_stdout if verbose? && !force_stdout.empty?
|
|
100
|
+
puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
|
|
101
|
+
:deleted
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def prune_force_delete_failed( branch:, upstream:, force_stderr: )
|
|
105
|
+
force_error_text = normalise_branch_delete_error( error_text: force_stderr )
|
|
106
|
+
puts_verbose "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
|
|
107
|
+
:skipped
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
|
|
111
|
+
puts_verbose "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
|
|
112
|
+
puts_verbose "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
|
|
113
|
+
:skipped
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def normalise_branch_delete_error( error_text: )
|
|
117
|
+
text = error_text.to_s.strip
|
|
118
|
+
text.empty? ? "unknown error" : text
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Detects local branches whose upstream tracking is marked [gone] after fetch --prune.
|
|
122
|
+
def stale_local_branches
|
|
123
|
+
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
|
|
124
|
+
branch, upstream, track = line.strip.split( "\t", 3 )
|
|
125
|
+
upstream = upstream.to_s
|
|
126
|
+
track = track.to_s
|
|
127
|
+
next if branch.to_s.empty? || upstream.empty?
|
|
128
|
+
next unless upstream.start_with?( "#{config.git_remote}/" ) && track.include?( "gone" )
|
|
129
|
+
|
|
130
|
+
{ branch: branch, upstream: upstream, track: track }
|
|
131
|
+
end.compact
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Detects local branches with no upstream tracking ref — candidates for orphan pruning.
|
|
135
|
+
def orphan_local_branches( active_branch: )
|
|
136
|
+
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.filter_map do |line|
|
|
137
|
+
branch, upstream = line.strip.split( "\t", 2 )
|
|
138
|
+
branch = branch.to_s.strip
|
|
139
|
+
upstream = upstream.to_s.strip
|
|
140
|
+
next if branch.empty?
|
|
141
|
+
next unless upstream.empty?
|
|
142
|
+
next if config.protected_branches.include?( branch )
|
|
143
|
+
next if branch == active_branch
|
|
144
|
+
next if branch == TEMPLATE_SYNC_BRANCH
|
|
145
|
+
|
|
146
|
+
branch
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Processes orphan branches: verifies merged PR evidence via GitHub API before deleting.
|
|
151
|
+
def prune_orphan_branch_entries( orphan_branches:, counters: )
|
|
152
|
+
return counters if orphan_branches.empty?
|
|
153
|
+
return counters unless gh_available?
|
|
154
|
+
|
|
155
|
+
orphan_branches.each do |branch|
|
|
156
|
+
outcome = prune_orphan_branch_entry( branch: branch )
|
|
157
|
+
counters[ outcome ] += 1
|
|
158
|
+
end
|
|
159
|
+
counters
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Checks a single orphan branch for merged PR evidence and force-deletes if confirmed.
|
|
163
|
+
def prune_orphan_branch_entry( branch: )
|
|
164
|
+
tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
|
|
165
|
+
unless tip_sha_success
|
|
166
|
+
error_text = tip_sha_error.to_s.strip
|
|
167
|
+
error_text = "unable to read local branch tip sha" if error_text.empty?
|
|
168
|
+
puts_verbose "skip_orphan_branch: #{branch} reason=#{error_text}"
|
|
169
|
+
return :skipped
|
|
170
|
+
end
|
|
171
|
+
branch_tip_sha = tip_sha_text.to_s.strip
|
|
172
|
+
if branch_tip_sha.empty?
|
|
173
|
+
puts_verbose "skip_orphan_branch: #{branch} reason=unable to read local branch tip sha"
|
|
174
|
+
return :skipped
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
merged_pr, error = merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
|
|
178
|
+
if merged_pr.nil?
|
|
179
|
+
reason = error.to_s.strip
|
|
180
|
+
reason = "no merged PR evidence for branch tip into #{config.main_branch}" if reason.empty?
|
|
181
|
+
puts_verbose "skip_orphan_branch: #{branch} reason=#{reason}"
|
|
182
|
+
return :skipped
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
|
|
186
|
+
if force_success
|
|
187
|
+
out.print force_stdout if verbose? && !force_stdout.empty?
|
|
188
|
+
puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
|
|
189
|
+
return :deleted
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
force_error_text = normalise_branch_delete_error( error_text: force_stderr )
|
|
193
|
+
puts_verbose "fail_delete_orphan_branch: #{branch} reason=#{force_error_text}"
|
|
194
|
+
:skipped
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Safe delete can fail after squash merges because branch tip is no longer an ancestor.
|
|
198
|
+
def non_merged_delete_error?( error_text: )
|
|
199
|
+
error_text.to_s.downcase.include?( "not fully merged" )
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Guarded force-delete policy for stale branches.
|
|
203
|
+
def force_delete_evidence_for_stale_branch( branch:, delete_error_text: )
|
|
204
|
+
return [ nil, "safe delete failure is not merge-related" ] unless non_merged_delete_error?( error_text: delete_error_text )
|
|
205
|
+
return [ nil, "gh CLI not available; cannot verify merged PR evidence" ] unless gh_available?
|
|
206
|
+
|
|
207
|
+
tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
|
|
208
|
+
unless tip_sha_success
|
|
209
|
+
error_text = tip_sha_error.to_s.strip
|
|
210
|
+
error_text = "unable to read local branch tip sha" if error_text.empty?
|
|
211
|
+
return [ nil, error_text ]
|
|
212
|
+
end
|
|
213
|
+
branch_tip_sha = tip_sha_text.to_s.strip
|
|
214
|
+
return [ nil, "unable to read local branch tip sha" ] if branch_tip_sha.empty?
|
|
215
|
+
|
|
216
|
+
merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Finds merged PR evidence for the exact local branch tip.
|
|
220
|
+
def merged_pr_for_branch( branch:, branch_tip_sha: )
|
|
221
|
+
owner, repo = repository_coordinates
|
|
222
|
+
results = []
|
|
223
|
+
page = 1
|
|
224
|
+
max_pages = 50
|
|
225
|
+
loop do
|
|
226
|
+
stdout_text, stderr_text, success, = gh_run(
|
|
227
|
+
"api", "repos/#{owner}/#{repo}/pulls",
|
|
228
|
+
"--method", "GET",
|
|
229
|
+
"-f", "state=closed",
|
|
230
|
+
"-f", "base=#{config.main_branch}",
|
|
231
|
+
"-f", "head=#{owner}:#{branch}",
|
|
232
|
+
"-f", "sort=updated",
|
|
233
|
+
"-f", "direction=desc",
|
|
234
|
+
"-f", "per_page=100",
|
|
235
|
+
"-f", "page=#{page}"
|
|
236
|
+
)
|
|
237
|
+
unless success
|
|
238
|
+
error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to query merged PR evidence for branch #{branch}" )
|
|
239
|
+
return [ nil, error_text ]
|
|
240
|
+
end
|
|
241
|
+
page_nodes = Array( JSON.parse( stdout_text ) )
|
|
242
|
+
break if page_nodes.empty?
|
|
243
|
+
|
|
244
|
+
page_nodes.each do |entry|
|
|
245
|
+
next unless entry.dig( "head", "ref" ).to_s == branch.to_s
|
|
246
|
+
next unless entry.dig( "base", "ref" ).to_s == config.main_branch
|
|
247
|
+
next unless entry.dig( "head", "sha" ).to_s == branch_tip_sha
|
|
248
|
+
|
|
249
|
+
merged_at = parse_time_or_nil( text: entry[ "merged_at" ] )
|
|
250
|
+
next if merged_at.nil?
|
|
251
|
+
|
|
252
|
+
results << {
|
|
253
|
+
number: entry[ "number" ],
|
|
254
|
+
url: entry[ "html_url" ].to_s,
|
|
255
|
+
merged_at: merged_at.utc.iso8601,
|
|
256
|
+
head_sha: entry.dig( "head", "sha" ).to_s
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
if page >= max_pages
|
|
260
|
+
probe_stdout_text, probe_stderr_text, probe_success, = gh_run(
|
|
261
|
+
"api", "repos/#{owner}/#{repo}/pulls",
|
|
262
|
+
"--method", "GET",
|
|
263
|
+
"-f", "state=closed",
|
|
264
|
+
"-f", "base=#{config.main_branch}",
|
|
265
|
+
"-f", "head=#{owner}:#{branch}",
|
|
266
|
+
"-f", "sort=updated",
|
|
267
|
+
"-f", "direction=desc",
|
|
268
|
+
"-f", "per_page=100",
|
|
269
|
+
"-f", "page=#{page + 1}"
|
|
270
|
+
)
|
|
271
|
+
unless probe_success
|
|
272
|
+
error_text = gh_error_text( stdout_text: probe_stdout_text, stderr_text: probe_stderr_text, fallback: "unable to verify merged PR pagination limit for branch #{branch}" )
|
|
273
|
+
return [ nil, error_text ]
|
|
274
|
+
end
|
|
275
|
+
probe_nodes = Array( JSON.parse( probe_stdout_text ) )
|
|
276
|
+
return [ nil, "merged PR lookup exceeded pagination safety limit (#{max_pages} pages) for branch #{branch}" ] unless probe_nodes.empty?
|
|
277
|
+
break
|
|
278
|
+
end
|
|
279
|
+
page += 1
|
|
280
|
+
end
|
|
281
|
+
latest = results.max_by { |item| item.fetch( :merged_at ) }
|
|
282
|
+
return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
|
|
283
|
+
|
|
284
|
+
[ latest, nil ]
|
|
285
|
+
rescue JSON::ParserError => e
|
|
286
|
+
[ nil, "invalid gh JSON response (#{e.message})" ]
|
|
287
|
+
rescue StandardError => e
|
|
288
|
+
[ nil, e.message ]
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|