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
data/lib/carson/runtime/local.rb
CHANGED
|
@@ -1,1230 +1,11 @@
|
|
|
1
|
+
require_relative "local/sync"
|
|
2
|
+
require_relative "local/prune"
|
|
3
|
+
require_relative "local/template"
|
|
4
|
+
require_relative "local/hooks"
|
|
5
|
+
require_relative "local/onboard"
|
|
6
|
+
|
|
1
7
|
module Carson
|
|
2
8
|
class Runtime
|
|
3
|
-
module Local
|
|
4
|
-
TEMPLATE_SYNC_BRANCH = "carson/template-sync".freeze
|
|
5
|
-
|
|
6
|
-
def sync!
|
|
7
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
8
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
9
|
-
|
|
10
|
-
unless working_tree_clean?
|
|
11
|
-
puts_line "BLOCK: working tree is dirty; commit/stash first, then run carson sync."
|
|
12
|
-
return EXIT_BLOCK
|
|
13
|
-
end
|
|
14
|
-
start_branch = current_branch
|
|
15
|
-
switched = false
|
|
16
|
-
git_system!( "fetch", config.git_remote, "--prune" )
|
|
17
|
-
if start_branch != config.main_branch
|
|
18
|
-
git_system!( "switch", config.main_branch )
|
|
19
|
-
switched = true
|
|
20
|
-
end
|
|
21
|
-
git_system!( "pull", "--ff-only", config.git_remote, config.main_branch )
|
|
22
|
-
ahead_count, behind_count, error_text = main_sync_counts
|
|
23
|
-
if error_text
|
|
24
|
-
puts_line "BLOCK: unable to verify main sync state (#{error_text})."
|
|
25
|
-
return EXIT_BLOCK
|
|
26
|
-
end
|
|
27
|
-
if ahead_count.zero? && behind_count.zero?
|
|
28
|
-
puts_line "OK: local #{config.main_branch} is now in sync with #{config.git_remote}/#{config.main_branch}."
|
|
29
|
-
return EXIT_OK
|
|
30
|
-
end
|
|
31
|
-
puts_line "BLOCK: local #{config.main_branch} still diverges (ahead=#{ahead_count}, behind=#{behind_count})."
|
|
32
|
-
EXIT_BLOCK
|
|
33
|
-
ensure
|
|
34
|
-
git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Removes stale local branches (gone upstream) and orphan branches (no tracking) with merged PR evidence.
|
|
38
|
-
def prune!
|
|
39
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
40
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
41
|
-
|
|
42
|
-
git_system!( "fetch", config.git_remote, "--prune" )
|
|
43
|
-
active_branch = current_branch
|
|
44
|
-
counters = { deleted: 0, skipped: 0 }
|
|
45
|
-
|
|
46
|
-
stale_branches = stale_local_branches
|
|
47
|
-
prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters )
|
|
48
|
-
|
|
49
|
-
orphan_branches = orphan_local_branches( active_branch: active_branch )
|
|
50
|
-
prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )
|
|
51
|
-
|
|
52
|
-
return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?
|
|
53
|
-
|
|
54
|
-
puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
|
|
55
|
-
unless verbose?
|
|
56
|
-
deleted_count = counters.fetch( :deleted )
|
|
57
|
-
if deleted_count.zero?
|
|
58
|
-
puts_line "No stale branches."
|
|
59
|
-
else
|
|
60
|
-
puts_line "Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
EXIT_OK
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def prune_no_stale_branches
|
|
67
|
-
if verbose?
|
|
68
|
-
puts_line "OK: no stale or orphan branches to prune."
|
|
69
|
-
else
|
|
70
|
-
puts_line "No stale branches."
|
|
71
|
-
end
|
|
72
|
-
EXIT_OK
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 } )
|
|
76
|
-
stale_branches.each do |entry|
|
|
77
|
-
outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
|
|
78
|
-
counters[ outcome ] += 1
|
|
79
|
-
end
|
|
80
|
-
counters
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def prune_stale_branch_entry( entry:, active_branch: )
|
|
84
|
-
branch = entry.fetch( :branch )
|
|
85
|
-
upstream = entry.fetch( :upstream )
|
|
86
|
-
return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
|
|
87
|
-
return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch
|
|
88
|
-
|
|
89
|
-
prune_delete_stale_branch( branch: branch, upstream: upstream )
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def prune_skip_stale_branch( type:, branch:, upstream: )
|
|
93
|
-
status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
|
|
94
|
-
puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
|
|
95
|
-
:skipped
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def prune_delete_stale_branch( branch:, upstream: )
|
|
99
|
-
stdout_text, stderr_text, success, = git_run( "branch", "-d", branch )
|
|
100
|
-
return prune_safe_delete_success( branch: branch, upstream: upstream, stdout_text: stdout_text ) if success
|
|
101
|
-
|
|
102
|
-
delete_error_text = normalise_branch_delete_error( error_text: stderr_text )
|
|
103
|
-
prune_force_delete_stale_branch(
|
|
104
|
-
branch: branch,
|
|
105
|
-
upstream: upstream,
|
|
106
|
-
delete_error_text: delete_error_text
|
|
107
|
-
)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def prune_safe_delete_success( branch:, upstream:, stdout_text: )
|
|
111
|
-
out.print stdout_text if verbose? && !stdout_text.empty?
|
|
112
|
-
puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
|
|
113
|
-
:deleted
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
|
|
117
|
-
merged_pr, force_error = force_delete_evidence_for_stale_branch(
|
|
118
|
-
branch: branch,
|
|
119
|
-
delete_error_text: delete_error_text
|
|
120
|
-
)
|
|
121
|
-
return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?
|
|
122
|
-
|
|
123
|
-
force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
|
|
124
|
-
return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success
|
|
125
|
-
|
|
126
|
-
prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
|
|
130
|
-
out.print force_stdout if verbose? && !force_stdout.empty?
|
|
131
|
-
puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
|
|
132
|
-
:deleted
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def prune_force_delete_failed( branch:, upstream:, force_stderr: )
|
|
136
|
-
force_error_text = normalise_branch_delete_error( error_text: force_stderr )
|
|
137
|
-
puts_verbose "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
|
|
138
|
-
:skipped
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
|
|
142
|
-
puts_verbose "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
|
|
143
|
-
puts_verbose "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
|
|
144
|
-
:skipped
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def normalise_branch_delete_error( error_text: )
|
|
148
|
-
text = error_text.to_s.strip
|
|
149
|
-
text.empty? ? "unknown error" : text
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Installs required hook files and enforces repository hook path.
|
|
153
|
-
def prepare!
|
|
154
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
155
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
156
|
-
|
|
157
|
-
FileUtils.mkdir_p( hooks_dir )
|
|
158
|
-
missing_templates = config.required_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
|
|
159
|
-
unless missing_templates.empty?
|
|
160
|
-
puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
|
|
161
|
-
return EXIT_BLOCK
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
symlinked = symlink_hook_files
|
|
165
|
-
unless symlinked.empty?
|
|
166
|
-
puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
|
|
167
|
-
return EXIT_BLOCK
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
config.required_hooks.each do |hook_name|
|
|
171
|
-
source_path = hook_template_path( hook_name: hook_name )
|
|
172
|
-
target_path = File.join( hooks_dir, hook_name )
|
|
173
|
-
FileUtils.cp( source_path, target_path )
|
|
174
|
-
FileUtils.chmod( 0o755, target_path )
|
|
175
|
-
puts_verbose "hook_written: #{relative_path( target_path )}"
|
|
176
|
-
end
|
|
177
|
-
git_system!( "config", "core.hooksPath", hooks_dir )
|
|
178
|
-
File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
|
|
179
|
-
puts_verbose "configured_hooks_path: #{hooks_dir}"
|
|
180
|
-
unless verbose?
|
|
181
|
-
puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
|
|
182
|
-
return EXIT_OK
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
inspect!
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# One-command onboarding for new repositories: detect remote, install hooks,
|
|
189
|
-
# apply templates, and run initial audit.
|
|
190
|
-
def onboard!
|
|
191
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
192
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
193
|
-
|
|
194
|
-
unless inside_git_work_tree?
|
|
195
|
-
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
196
|
-
return EXIT_ERROR
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
repo_name = File.basename( repo_root )
|
|
200
|
-
puts_line ""
|
|
201
|
-
puts_line "Onboarding #{repo_name}..."
|
|
202
|
-
|
|
203
|
-
if !global_config_exists? || !git_remote_exists?( remote_name: config.git_remote )
|
|
204
|
-
if self.in.respond_to?( :tty? ) && self.in.tty?
|
|
205
|
-
setup_status = setup!
|
|
206
|
-
return setup_status unless setup_status == EXIT_OK
|
|
207
|
-
else
|
|
208
|
-
silent_setup!
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
onboard_apply!
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Re-applies hooks, templates, and audit after upgrading Carson.
|
|
216
|
-
def refresh!
|
|
217
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
218
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
219
|
-
|
|
220
|
-
unless inside_git_work_tree?
|
|
221
|
-
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
222
|
-
return EXIT_ERROR
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
if verbose?
|
|
226
|
-
puts_verbose ""
|
|
227
|
-
puts_verbose "[Refresh]"
|
|
228
|
-
hook_status = prepare!
|
|
229
|
-
return hook_status unless hook_status == EXIT_OK
|
|
230
|
-
|
|
231
|
-
drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
232
|
-
template_status = template_apply!
|
|
233
|
-
return template_status unless template_status == EXIT_OK
|
|
234
|
-
|
|
235
|
-
@template_sync_result = template_propagate!( drift_count: drift_count )
|
|
236
|
-
|
|
237
|
-
audit_status = audit!
|
|
238
|
-
if audit_status == EXIT_OK
|
|
239
|
-
puts_line "OK: Carson refresh completed for #{repo_root}."
|
|
240
|
-
elsif audit_status == EXIT_BLOCK
|
|
241
|
-
puts_line "BLOCK: Carson refresh completed with policy blocks; resolve and rerun carson audit."
|
|
242
|
-
end
|
|
243
|
-
return audit_status
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
puts_line "Refresh"
|
|
247
|
-
hook_status = with_captured_output { prepare! }
|
|
248
|
-
return hook_status unless hook_status == EXIT_OK
|
|
249
|
-
puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
|
|
250
|
-
|
|
251
|
-
template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
252
|
-
template_status = with_captured_output { template_apply! }
|
|
253
|
-
return template_status unless template_status == EXIT_OK
|
|
254
|
-
if template_drift_count.positive?
|
|
255
|
-
puts_line "Templates applied (#{template_drift_count} updated)."
|
|
256
|
-
else
|
|
257
|
-
puts_line "Templates in sync."
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
@template_sync_result = template_propagate!( drift_count: template_drift_count )
|
|
261
|
-
|
|
262
|
-
audit_status = audit!
|
|
263
|
-
puts_line "Refresh complete."
|
|
264
|
-
audit_status
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
# Re-applies hooks, templates, and audit across all governed repositories.
|
|
268
|
-
def refresh_all!
|
|
269
|
-
repos = config.govern_repos
|
|
270
|
-
if repos.empty?
|
|
271
|
-
puts_line "No governed repositories configured."
|
|
272
|
-
puts_line " Run carson onboard in each repo to register."
|
|
273
|
-
return EXIT_ERROR
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
puts_line ""
|
|
277
|
-
puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
|
|
278
|
-
refreshed = 0
|
|
279
|
-
failed = 0
|
|
280
|
-
|
|
281
|
-
repos.each do |repo_path|
|
|
282
|
-
repo_name = File.basename( repo_path )
|
|
283
|
-
unless Dir.exist?( repo_path )
|
|
284
|
-
puts_line "#{repo_name}: FAIL (path not found)"
|
|
285
|
-
failed += 1
|
|
286
|
-
next
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
|
|
290
|
-
if status == EXIT_ERROR
|
|
291
|
-
failed += 1
|
|
292
|
-
else
|
|
293
|
-
refreshed += 1
|
|
294
|
-
end
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
puts_line ""
|
|
298
|
-
puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
|
|
299
|
-
failed.zero? ? EXIT_OK : EXIT_ERROR
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
# Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
|
|
303
|
-
def offboard!
|
|
304
|
-
puts_verbose ""
|
|
305
|
-
puts_verbose "[Offboard]"
|
|
306
|
-
unless inside_git_work_tree?
|
|
307
|
-
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
308
|
-
return EXIT_ERROR
|
|
309
|
-
end
|
|
310
|
-
hooks_status = disable_carson_hooks_path!
|
|
311
|
-
return hooks_status unless hooks_status == EXIT_OK
|
|
312
|
-
|
|
313
|
-
removed_count = 0
|
|
314
|
-
missing_count = 0
|
|
315
|
-
offboard_cleanup_targets.each do |relative|
|
|
316
|
-
absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" )
|
|
317
|
-
if File.exist?( absolute )
|
|
318
|
-
FileUtils.rm_rf( absolute )
|
|
319
|
-
puts_verbose "removed_path: #{relative}"
|
|
320
|
-
removed_count += 1
|
|
321
|
-
else
|
|
322
|
-
puts_verbose "skip_missing_path: #{relative}"
|
|
323
|
-
missing_count += 1
|
|
324
|
-
end
|
|
325
|
-
end
|
|
326
|
-
remove_empty_offboard_directories!
|
|
327
|
-
remove_govern_repo!( repo_path: File.expand_path( repo_root ) )
|
|
328
|
-
puts_verbose "govern_deregistered: #{File.expand_path( repo_root )}"
|
|
329
|
-
puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
|
|
330
|
-
if verbose?
|
|
331
|
-
puts_line "OK: Carson offboard completed for #{repo_root}."
|
|
332
|
-
else
|
|
333
|
-
puts_line "Removed #{removed_count} file#{plural_suffix( count: removed_count )}. Offboard complete."
|
|
334
|
-
end
|
|
335
|
-
EXIT_OK
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
# Strict hook health check used by humans, hooks, and CI paths.
|
|
339
|
-
def inspect!
|
|
340
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
341
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
342
|
-
|
|
343
|
-
puts_verbose ""
|
|
344
|
-
puts_verbose "[Inspect]"
|
|
345
|
-
ok = hooks_health_report( strict: true )
|
|
346
|
-
puts_verbose( ok ? "status: ok" : "status: block" )
|
|
347
|
-
unless verbose?
|
|
348
|
-
puts_line( ok ? "Hooks: ok" : "Hooks: block" )
|
|
349
|
-
end
|
|
350
|
-
ok ? EXIT_OK : EXIT_BLOCK
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# Read-only template drift check; returns block when managed files are out of sync.
|
|
354
|
-
def template_check!
|
|
355
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
356
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
357
|
-
|
|
358
|
-
puts_verbose ""
|
|
359
|
-
puts_verbose "[Template Sync Check]"
|
|
360
|
-
results = template_results
|
|
361
|
-
stale = template_superseded_present
|
|
362
|
-
drift_count = results.count { |entry| entry.fetch( :status ) == "drift" }
|
|
363
|
-
error_count = results.count { |entry| entry.fetch( :status ) == "error" }
|
|
364
|
-
stale_count = stale.count
|
|
365
|
-
results.each do |entry|
|
|
366
|
-
puts_verbose "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
|
|
367
|
-
end
|
|
368
|
-
stale.each { |file| puts_verbose "template_file: #{file} status=stale reason=superseded" }
|
|
369
|
-
puts_verbose "template_summary: total=#{results.count} drift=#{drift_count} stale=#{stale_count} error=#{error_count}"
|
|
370
|
-
unless verbose?
|
|
371
|
-
if ( drift_count + stale_count ).positive?
|
|
372
|
-
summary_parts = []
|
|
373
|
-
summary_parts << "#{drift_count} of #{results.count} drifted" if drift_count.positive?
|
|
374
|
-
summary_parts << "#{stale_count} stale" if stale_count.positive?
|
|
375
|
-
puts_line "Templates: #{summary_parts.join( ", " )}"
|
|
376
|
-
results.select { |entry| entry.fetch( :status ) == "drift" }.each { |entry| puts_line " #{entry.fetch( :file )}" }
|
|
377
|
-
stale.each { |file| puts_line " #{file} — superseded" }
|
|
378
|
-
else
|
|
379
|
-
puts_line "Templates: #{results.count} files in sync"
|
|
380
|
-
end
|
|
381
|
-
end
|
|
382
|
-
return EXIT_ERROR if error_count.positive?
|
|
383
|
-
|
|
384
|
-
( drift_count + stale_count ).positive? ? EXIT_BLOCK : EXIT_OK
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
# Applies managed template files as full-file writes from Carson sources.
|
|
388
|
-
# Also removes superseded files that are no longer part of the managed set.
|
|
389
|
-
def template_apply!( push_prep: false )
|
|
390
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
391
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
392
|
-
|
|
393
|
-
puts_verbose ""
|
|
394
|
-
puts_verbose "[Template Sync Apply]"
|
|
395
|
-
results = template_results
|
|
396
|
-
stale = template_superseded_present
|
|
397
|
-
applied = 0
|
|
398
|
-
results.each do |entry|
|
|
399
|
-
if entry.fetch( :status ) == "error"
|
|
400
|
-
puts_verbose "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}"
|
|
401
|
-
next
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
file_path = File.join( repo_root, entry.fetch( :file ) )
|
|
405
|
-
if entry.fetch( :status ) == "ok"
|
|
406
|
-
puts_verbose "template_file: #{entry.fetch( :file )} status=ok reason=in_sync"
|
|
407
|
-
next
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
FileUtils.mkdir_p( File.dirname( file_path ) )
|
|
411
|
-
File.write( file_path, entry.fetch( :applied_content ) )
|
|
412
|
-
puts_verbose "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}"
|
|
413
|
-
applied += 1
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
removed = 0
|
|
417
|
-
stale.each do |file|
|
|
418
|
-
file_path = resolve_repo_path!( relative_path: file, label: "template.superseded_files entry #{file}" )
|
|
419
|
-
File.delete( file_path )
|
|
420
|
-
puts_verbose "template_file: #{file} status=removed reason=superseded"
|
|
421
|
-
removed += 1
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
error_count = results.count { |entry| entry.fetch( :status ) == "error" }
|
|
425
|
-
puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}"
|
|
426
|
-
unless verbose?
|
|
427
|
-
if applied.positive? || removed.positive?
|
|
428
|
-
summary_parts = []
|
|
429
|
-
summary_parts << "#{applied} updated" if applied.positive?
|
|
430
|
-
summary_parts << "#{removed} removed" if removed.positive?
|
|
431
|
-
puts_line "Templates applied (#{summary_parts.join( ", " )})."
|
|
432
|
-
else
|
|
433
|
-
puts_line "Templates in sync."
|
|
434
|
-
end
|
|
435
|
-
end
|
|
436
|
-
return EXIT_ERROR if error_count.positive?
|
|
437
|
-
|
|
438
|
-
return EXIT_BLOCK if push_prep && push_prep_commit!
|
|
439
|
-
EXIT_OK
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
private
|
|
443
|
-
|
|
444
|
-
# Orchestrates worktree-based template propagation to the remote.
|
|
445
|
-
# Skips silently when there is no drift or no remote configured.
|
|
446
|
-
# Returns a result hash stored in @template_sync_result.
|
|
447
|
-
def template_propagate!( drift_count: )
|
|
448
|
-
if drift_count.zero?
|
|
449
|
-
puts_verbose "template_propagate: skip (no drift)"
|
|
450
|
-
return { status: :skip, reason: "no drift" }
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
unless git_remote_exists?( remote_name: config.git_remote )
|
|
454
|
-
puts_verbose "template_propagate: skip (no remote #{config.git_remote})"
|
|
455
|
-
return { status: :skip, reason: "no remote" }
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
worktree_dir = nil
|
|
459
|
-
begin
|
|
460
|
-
worktree_dir = template_propagate_create_worktree!
|
|
461
|
-
template_propagate_write_files!( worktree_dir: worktree_dir )
|
|
462
|
-
committed = template_propagate_commit!( worktree_dir: worktree_dir )
|
|
463
|
-
unless committed
|
|
464
|
-
puts_verbose "template_propagate: skip (no changes after write)"
|
|
465
|
-
return { status: :skip, reason: "no changes" }
|
|
466
|
-
end
|
|
467
|
-
result = template_propagate_deliver!( worktree_dir: worktree_dir )
|
|
468
|
-
template_propagate_report!( result: result )
|
|
469
|
-
result
|
|
470
|
-
rescue StandardError => e
|
|
471
|
-
puts_verbose "template_propagate: error (#{e.message})"
|
|
472
|
-
{ status: :error, reason: e.message }
|
|
473
|
-
ensure
|
|
474
|
-
template_propagate_cleanup!( worktree_dir: worktree_dir ) if worktree_dir
|
|
475
|
-
end
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# Creates a detached worktree from the remote main, checks out the sync branch,
|
|
479
|
-
# and disables hooks so Carson's own pre-commit never fires inside the worktree.
|
|
480
|
-
def template_propagate_create_worktree!
|
|
481
|
-
worktree_dir = File.join( Dir.tmpdir, "carson-template-sync-#{Process.pid}-#{Time.now.to_i}" )
|
|
482
|
-
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
483
|
-
|
|
484
|
-
git_system!( "fetch", config.git_remote, config.main_branch )
|
|
485
|
-
git_system!( "worktree", "add", "--detach", worktree_dir, "#{config.git_remote}/#{config.main_branch}" )
|
|
486
|
-
wt_git.run( "checkout", "-B", TEMPLATE_SYNC_BRANCH )
|
|
487
|
-
wt_git.run( "config", "core.hooksPath", "/dev/null" )
|
|
488
|
-
puts_verbose "template_propagate: worktree created at #{worktree_dir}"
|
|
489
|
-
worktree_dir
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
# Copies all Carson template source files into the worktree.
|
|
493
|
-
# Also removes superseded files present in the worktree.
|
|
494
|
-
def template_propagate_write_files!( worktree_dir: )
|
|
495
|
-
config.template_managed_files.each do |managed_file|
|
|
496
|
-
template_path = template_source_path( managed_file: managed_file )
|
|
497
|
-
next if template_path.nil?
|
|
498
|
-
|
|
499
|
-
target_path = File.join( worktree_dir, managed_file )
|
|
500
|
-
FileUtils.mkdir_p( File.dirname( target_path ) )
|
|
501
|
-
expected_content = normalize_text( text: File.read( template_path ) )
|
|
502
|
-
File.write( target_path, expected_content )
|
|
503
|
-
puts_verbose "template_propagate: wrote #{managed_file}"
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
template_superseded_present_in( root: worktree_dir ).each do |file|
|
|
507
|
-
file_path = File.join( worktree_dir, file )
|
|
508
|
-
File.delete( file_path )
|
|
509
|
-
puts_verbose "template_propagate: removed superseded #{file}"
|
|
510
|
-
end
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
# Stages all changes in the worktree and commits if there is an actual diff.
|
|
514
|
-
# Returns true if a commit was created, false if worktree content matches remote.
|
|
515
|
-
def template_propagate_commit!( worktree_dir: )
|
|
516
|
-
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
517
|
-
wt_git.run( "add", "--all" )
|
|
518
|
-
|
|
519
|
-
_, _, no_diff, = wt_git.run( "diff", "--cached", "--quiet" )
|
|
520
|
-
return false if no_diff
|
|
521
|
-
|
|
522
|
-
wt_git.run( "commit", "-m", "chore: sync Carson #{Carson::VERSION} managed templates" )
|
|
523
|
-
puts_verbose "template_propagate: committed"
|
|
524
|
-
true
|
|
525
|
-
end
|
|
526
|
-
|
|
527
|
-
# Dispatches to trunk or branch delivery based on workflow style.
|
|
528
|
-
def template_propagate_deliver!( worktree_dir: )
|
|
529
|
-
if config.workflow_style == "trunk"
|
|
530
|
-
template_propagate_deliver_trunk!( worktree_dir: worktree_dir )
|
|
531
|
-
else
|
|
532
|
-
template_propagate_deliver_branch!( worktree_dir: worktree_dir )
|
|
533
|
-
end
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
# Trunk mode: push template changes directly to main.
|
|
537
|
-
def template_propagate_deliver_trunk!( worktree_dir: )
|
|
538
|
-
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
539
|
-
stdout_text, stderr_text, success, = wt_git.run( "push", config.git_remote, "HEAD:refs/heads/#{config.main_branch}" )
|
|
540
|
-
unless success
|
|
541
|
-
error_text = stderr_text.to_s.strip
|
|
542
|
-
error_text = "push to #{config.main_branch} failed" if error_text.empty?
|
|
543
|
-
raise error_text
|
|
544
|
-
end
|
|
545
|
-
puts_verbose "template_propagate: pushed to #{config.main_branch}"
|
|
546
|
-
{ status: :pushed, ref: config.main_branch }
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
# Branch mode: force-push the sync branch and ensure a PR exists.
|
|
550
|
-
def template_propagate_deliver_branch!( worktree_dir: )
|
|
551
|
-
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
552
|
-
stdout_text, stderr_text, success, = wt_git.run( "push", "--force-with-lease", config.git_remote, "#{TEMPLATE_SYNC_BRANCH}:#{TEMPLATE_SYNC_BRANCH}" )
|
|
553
|
-
unless success
|
|
554
|
-
error_text = stderr_text.to_s.strip
|
|
555
|
-
error_text = "push #{TEMPLATE_SYNC_BRANCH} failed" if error_text.empty?
|
|
556
|
-
raise error_text
|
|
557
|
-
end
|
|
558
|
-
puts_verbose "template_propagate: pushed #{TEMPLATE_SYNC_BRANCH}"
|
|
559
|
-
|
|
560
|
-
pr_url = template_propagate_ensure_pr!( worktree_dir: worktree_dir )
|
|
561
|
-
{ status: :pr, branch: TEMPLATE_SYNC_BRANCH, pr_url: pr_url }
|
|
562
|
-
end
|
|
563
|
-
|
|
564
|
-
# Checks for an existing open PR from the sync branch; creates one if none exists.
|
|
565
|
-
# Returns the PR URL.
|
|
566
|
-
def template_propagate_ensure_pr!( worktree_dir: )
|
|
567
|
-
wt_gh = Adapters::GitHub.new( repo_root: worktree_dir )
|
|
568
|
-
|
|
569
|
-
# Check for existing open PR.
|
|
570
|
-
stdout_text, _, success, = wt_gh.run(
|
|
571
|
-
"pr", "list",
|
|
572
|
-
"--head", TEMPLATE_SYNC_BRANCH,
|
|
573
|
-
"--base", config.main_branch,
|
|
574
|
-
"--state", "open",
|
|
575
|
-
"--json", "url",
|
|
576
|
-
"--jq", ".[0].url"
|
|
577
|
-
)
|
|
578
|
-
existing_url = stdout_text.to_s.strip
|
|
579
|
-
if success && !existing_url.empty?
|
|
580
|
-
puts_verbose "template_propagate: existing PR #{existing_url}"
|
|
581
|
-
return existing_url
|
|
582
|
-
end
|
|
583
|
-
|
|
584
|
-
# Create new PR.
|
|
585
|
-
stdout_text, stderr_text, success, = wt_gh.run(
|
|
586
|
-
"pr", "create",
|
|
587
|
-
"--head", TEMPLATE_SYNC_BRANCH,
|
|
588
|
-
"--base", config.main_branch,
|
|
589
|
-
"--title", "chore: sync Carson #{Carson::VERSION} managed templates",
|
|
590
|
-
"--body", "Auto-generated by `carson refresh`.\n\nUpdates managed template files to match Carson #{Carson::VERSION}."
|
|
591
|
-
)
|
|
592
|
-
unless success
|
|
593
|
-
error_text = stderr_text.to_s.strip
|
|
594
|
-
error_text = "gh pr create failed" if error_text.empty?
|
|
595
|
-
raise error_text
|
|
596
|
-
end
|
|
597
|
-
pr_url = stdout_text.to_s.strip
|
|
598
|
-
puts_verbose "template_propagate: created PR #{pr_url}"
|
|
599
|
-
pr_url
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
# Removes the worktree and the local sync branch it created.
|
|
603
|
-
def template_propagate_cleanup!( worktree_dir: )
|
|
604
|
-
git_run( "worktree", "remove", "--force", worktree_dir )
|
|
605
|
-
git_run( "branch", "-D", TEMPLATE_SYNC_BRANCH )
|
|
606
|
-
puts_verbose "template_propagate: worktree and local branch cleaned up"
|
|
607
|
-
rescue StandardError => e
|
|
608
|
-
puts_verbose "template_propagate: cleanup warning (#{e.message})"
|
|
609
|
-
end
|
|
610
|
-
|
|
611
|
-
# Prints a human-readable summary of the propagation result.
|
|
612
|
-
def template_propagate_report!( result: )
|
|
613
|
-
case result.fetch( :status )
|
|
614
|
-
when :pushed
|
|
615
|
-
puts_line "Templates pushed to #{result.fetch( :ref )}."
|
|
616
|
-
when :pr
|
|
617
|
-
puts_line "Template sync PR: #{result.fetch( :pr_url )}"
|
|
618
|
-
end
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
# Checks which superseded files exist under an arbitrary root directory.
|
|
622
|
-
def template_superseded_present_in( root: )
|
|
623
|
-
config.template_superseded_files.select do |file|
|
|
624
|
-
File.file?( File.join( root, file ) )
|
|
625
|
-
end
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
def refresh_sync_suffix( result: )
|
|
629
|
-
return "" if result.nil?
|
|
630
|
-
|
|
631
|
-
case result.fetch( :status )
|
|
632
|
-
when :pushed then " (templates pushed to #{result.fetch( :ref )})"
|
|
633
|
-
when :pr then " (PR: #{result.fetch( :pr_url )})"
|
|
634
|
-
else ""
|
|
635
|
-
end
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
# Refreshes a single governed repository using a scoped Runtime.
|
|
639
|
-
def refresh_single_repo( repo_path:, repo_name: )
|
|
640
|
-
if verbose?
|
|
641
|
-
rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err, verbose: true )
|
|
642
|
-
else
|
|
643
|
-
rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: StringIO.new, err: StringIO.new )
|
|
644
|
-
end
|
|
645
|
-
status = rt.refresh!
|
|
646
|
-
label = refresh_status_label( status: status )
|
|
647
|
-
sync_suffix = refresh_sync_suffix( result: rt.template_sync_result )
|
|
648
|
-
puts_line "#{repo_name}: #{label}#{sync_suffix}"
|
|
649
|
-
status
|
|
650
|
-
rescue StandardError => e
|
|
651
|
-
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
652
|
-
EXIT_ERROR
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
def refresh_status_label( status: )
|
|
656
|
-
case status
|
|
657
|
-
when EXIT_OK then "OK"
|
|
658
|
-
when EXIT_BLOCK then "BLOCK"
|
|
659
|
-
else "FAIL"
|
|
660
|
-
end
|
|
661
|
-
end
|
|
662
|
-
|
|
663
|
-
def template_results
|
|
664
|
-
config.template_managed_files.map { |managed_file| template_result_for_file( managed_file: managed_file ) }
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
def template_superseded_present
|
|
668
|
-
config.template_superseded_files.select do |file|
|
|
669
|
-
file_path = resolve_repo_path!( relative_path: file, label: "template.superseded_files entry #{file}" )
|
|
670
|
-
File.file?( file_path )
|
|
671
|
-
end
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
# Calculates whole-file expected content and returns sync status plus apply payload.
|
|
675
|
-
def template_result_for_file( managed_file: )
|
|
676
|
-
template_path = template_source_path( managed_file: managed_file )
|
|
677
|
-
return { file: managed_file, status: "error", reason: "missing template #{File.basename( managed_file )}", applied_content: nil } if template_path.nil?
|
|
678
|
-
|
|
679
|
-
expected_content = normalize_text( text: File.read( template_path ) )
|
|
680
|
-
file_path = resolve_repo_path!( relative_path: managed_file, label: "template.managed_files entry #{managed_file}" )
|
|
681
|
-
return { file: managed_file, status: "drift", reason: "missing_file", applied_content: expected_content } unless File.file?( file_path )
|
|
682
|
-
|
|
683
|
-
current_content = normalize_text( text: File.read( file_path ) )
|
|
684
|
-
return { file: managed_file, status: "ok", reason: "in_sync", applied_content: current_content } if current_content == expected_content
|
|
685
|
-
|
|
686
|
-
{ file: managed_file, status: "drift", reason: "content_mismatch", applied_content: expected_content }
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
# Uses LF-only normalisation so platform newlines do not cause false drift.
|
|
690
|
-
def normalize_text( text: )
|
|
691
|
-
"#{text.to_s.gsub( "\r\n", "\n" ).rstrip}\n"
|
|
692
|
-
end
|
|
693
|
-
|
|
694
|
-
# GitHub managed template source directory inside Carson repository.
|
|
695
|
-
def github_templates_dir
|
|
696
|
-
File.join( tool_root, "templates", ".github" )
|
|
697
|
-
end
|
|
698
|
-
|
|
699
|
-
# Resolves the source file path for a managed template.
|
|
700
|
-
# Checks the user's canonical directory first, then falls back to Carson's built-in templates.
|
|
701
|
-
def template_source_path( managed_file: )
|
|
702
|
-
relative_within_github = managed_file.delete_prefix( ".github/" )
|
|
703
|
-
|
|
704
|
-
# Canonical source: the user's canonical .github/ files.
|
|
705
|
-
canonical = config.template_canonical
|
|
706
|
-
if canonical && !canonical.empty?
|
|
707
|
-
canonical_path = File.join( canonical, relative_within_github )
|
|
708
|
-
return canonical_path if File.file?( canonical_path )
|
|
709
|
-
end
|
|
710
|
-
|
|
711
|
-
# Carson built-in templates: subdirectory-aware path first, then flat basename fallback.
|
|
712
|
-
template_path = File.join( github_templates_dir, relative_within_github )
|
|
713
|
-
return template_path if File.file?( template_path )
|
|
714
|
-
|
|
715
|
-
basename_path = File.join( github_templates_dir, File.basename( managed_file ) )
|
|
716
|
-
return basename_path if File.file?( basename_path )
|
|
717
|
-
|
|
718
|
-
nil
|
|
719
|
-
end
|
|
720
|
-
|
|
721
|
-
# Canonical hook template location inside Carson repository.
|
|
722
|
-
def hook_template_path( hook_name: )
|
|
723
|
-
File.join( tool_root, "hooks", hook_name )
|
|
724
|
-
end
|
|
725
|
-
|
|
726
|
-
# Reports full hook health and can enforce stricter action messaging in `check`.
|
|
727
|
-
def hooks_health_report( strict: false )
|
|
728
|
-
configured = configured_hooks_path
|
|
729
|
-
expected = hooks_dir
|
|
730
|
-
hooks_path_ok = print_hooks_path_status( configured: configured, expected: expected )
|
|
731
|
-
print_required_hook_status
|
|
732
|
-
hooks_integrity = hook_integrity_state
|
|
733
|
-
hooks_ok = hooks_integrity_ok?( hooks_integrity: hooks_integrity )
|
|
734
|
-
print_hook_action(
|
|
735
|
-
strict: strict,
|
|
736
|
-
hooks_ok: hooks_path_ok && hooks_ok,
|
|
737
|
-
hooks_path_ok: hooks_path_ok,
|
|
738
|
-
configured: configured,
|
|
739
|
-
expected: expected
|
|
740
|
-
)
|
|
741
|
-
hooks_path_ok && hooks_ok
|
|
742
|
-
end
|
|
743
|
-
|
|
744
|
-
def print_hooks_path_status( configured:, expected: )
|
|
745
|
-
configured_abs = configured.nil? ? nil : File.expand_path( configured )
|
|
746
|
-
hooks_path_ok = configured_abs == expected
|
|
747
|
-
puts_verbose "hooks_path: #{configured || '(unset)'}"
|
|
748
|
-
puts_verbose "hooks_path_expected: #{expected}"
|
|
749
|
-
puts_verbose( hooks_path_ok ? "hooks_path_status: ok" : "hooks_path_status: attention" )
|
|
750
|
-
hooks_path_ok
|
|
751
|
-
end
|
|
752
|
-
|
|
753
|
-
def print_required_hook_status
|
|
754
|
-
required_hook_paths.each do |path|
|
|
755
|
-
exists = File.file?( path )
|
|
756
|
-
symlink = File.symlink?( path )
|
|
757
|
-
executable = exists && !symlink && File.executable?( path )
|
|
758
|
-
puts_verbose "hook_file: #{relative_path( path )} exists=#{exists} symlink=#{symlink} executable=#{executable}"
|
|
759
|
-
end
|
|
760
|
-
end
|
|
761
|
-
|
|
762
|
-
def hook_integrity_state
|
|
763
|
-
{
|
|
764
|
-
missing: missing_hook_files,
|
|
765
|
-
non_executable: non_executable_hook_files,
|
|
766
|
-
symlinked: symlink_hook_files
|
|
767
|
-
}
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
def hooks_integrity_ok?( hooks_integrity: )
|
|
771
|
-
missing_ok = hooks_integrity.fetch( :missing ).empty?
|
|
772
|
-
non_executable_ok = hooks_integrity.fetch( :non_executable ).empty?
|
|
773
|
-
symlinked_ok = hooks_integrity.fetch( :symlinked ).empty?
|
|
774
|
-
missing_ok && non_executable_ok && symlinked_ok
|
|
775
|
-
end
|
|
776
|
-
|
|
777
|
-
def print_hook_action( strict:, hooks_ok:, hooks_path_ok:, configured:, expected: )
|
|
778
|
-
return if hooks_ok
|
|
779
|
-
|
|
780
|
-
if strict && !hooks_path_ok
|
|
781
|
-
configured_text = configured.to_s.strip
|
|
782
|
-
if configured_text.empty?
|
|
783
|
-
puts_verbose "ACTION: hooks path is unset (expected=#{expected})."
|
|
784
|
-
else
|
|
785
|
-
puts_verbose "ACTION: hooks path mismatch (configured=#{configured_text}, expected=#{expected})."
|
|
786
|
-
end
|
|
787
|
-
end
|
|
788
|
-
message = strict ? "ACTION: run carson prepare to align hooks with Carson #{Carson::VERSION}." : "ACTION: run carson prepare to enforce local main protections."
|
|
789
|
-
puts_verbose message
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
# Returns ahead/behind counts for local main versus configured remote main.
|
|
793
|
-
def main_sync_counts
|
|
794
|
-
target = "#{config.main_branch}...#{config.git_remote}/#{config.main_branch}"
|
|
795
|
-
stdout_text, stderr_text, success, = git_run( "rev-list", "--left-right", "--count", target )
|
|
796
|
-
unless success
|
|
797
|
-
error_text = stderr_text.to_s.strip
|
|
798
|
-
error_text = "git rev-list failed" if error_text.empty?
|
|
799
|
-
return [ 0, 0, error_text ]
|
|
800
|
-
end
|
|
801
|
-
counts = stdout_text.to_s.strip.split( /\s+/ )
|
|
802
|
-
return [ 0, 0, "unexpected rev-list output: #{stdout_text.to_s.strip}" ] if counts.length < 2
|
|
803
|
-
|
|
804
|
-
[ counts[ 0 ].to_i, counts[ 1 ].to_i, nil ]
|
|
805
|
-
end
|
|
806
|
-
|
|
807
|
-
# Reads configured core.hooksPath and normalises empty values to nil.
|
|
808
|
-
def configured_hooks_path
|
|
809
|
-
stdout_text, = git_capture_soft( "config", "--get", "core.hooksPath" )
|
|
810
|
-
value = stdout_text.to_s.strip
|
|
811
|
-
value.empty? ? nil : value
|
|
812
|
-
end
|
|
813
|
-
|
|
814
|
-
# Fully-qualified required hook file locations in the target repository.
|
|
815
|
-
def required_hook_paths
|
|
816
|
-
config.required_hooks.map { |name| File.join( hooks_dir, name ) }
|
|
817
|
-
end
|
|
818
|
-
|
|
819
|
-
# Missing required hook files.
|
|
820
|
-
def missing_hook_files
|
|
821
|
-
required_hook_paths.reject { |path| File.file?( path ) }.map { |path| relative_path( path ) }
|
|
822
|
-
end
|
|
823
|
-
|
|
824
|
-
# Required hook files that exist but are not executable.
|
|
825
|
-
def non_executable_hook_files
|
|
826
|
-
required_hook_paths.select { |path| File.file?( path ) && !File.executable?( path ) }.map { |path| relative_path( path ) }
|
|
827
|
-
end
|
|
828
|
-
|
|
829
|
-
# Symlink hooks are disallowed to prevent bypassing managed hook content.
|
|
830
|
-
def symlink_hook_files
|
|
831
|
-
required_hook_paths.select { |path| File.symlink?( path ) }.map { |path| relative_path( path ) }
|
|
832
|
-
end
|
|
833
|
-
|
|
834
|
-
# Local directory where managed hooks are installed.
|
|
835
|
-
def hooks_dir
|
|
836
|
-
File.expand_path( File.join( config.hooks_base_path, Carson::VERSION ) )
|
|
837
|
-
end
|
|
838
|
-
|
|
839
|
-
# In outsider mode, Carson must not leave Carson-owned fingerprints in host repositories.
|
|
840
|
-
def block_if_outsider_fingerprints!
|
|
841
|
-
return nil unless outsider_mode?
|
|
842
|
-
|
|
843
|
-
violations = outsider_fingerprint_violations
|
|
844
|
-
return nil if violations.empty?
|
|
845
|
-
|
|
846
|
-
violations.each { |entry| puts_line "BLOCK: #{entry}" }
|
|
847
|
-
EXIT_BLOCK
|
|
848
|
-
end
|
|
849
|
-
|
|
850
|
-
# Carson source repository itself is excluded from host-repository fingerprint checks.
|
|
851
|
-
def outsider_mode?
|
|
852
|
-
File.expand_path( repo_root ) != File.expand_path( tool_root )
|
|
853
|
-
end
|
|
854
|
-
|
|
855
|
-
# Detects Carson-owned host artefacts that violate outsider boundary.
|
|
856
|
-
def outsider_fingerprint_violations
|
|
857
|
-
violations = []
|
|
858
|
-
violations << "forbidden file .carson.yml detected" if File.file?( File.join( repo_root, ".carson.yml" ) )
|
|
859
|
-
violations << "forbidden file bin/carson detected" if File.file?( File.join( repo_root, "bin", "carson" ) )
|
|
860
|
-
violations << "forbidden directory .tools/carson detected" if Dir.exist?( File.join( repo_root, ".tools", "carson" ) )
|
|
861
|
-
violations
|
|
862
|
-
end
|
|
863
|
-
|
|
864
|
-
# Detects local branches whose upstream tracking is marked [gone] after fetch --prune.
|
|
865
|
-
# Branches without upstream tracking are handled separately by orphan_local_branches.
|
|
866
|
-
def stale_local_branches
|
|
867
|
-
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
|
|
868
|
-
branch, upstream, track = line.strip.split( "\t", 3 )
|
|
869
|
-
upstream = upstream.to_s
|
|
870
|
-
track = track.to_s
|
|
871
|
-
next if branch.to_s.empty? || upstream.empty?
|
|
872
|
-
next unless upstream.start_with?( "#{config.git_remote}/" ) && track.include?( "gone" )
|
|
873
|
-
|
|
874
|
-
{ branch: branch, upstream: upstream, track: track }
|
|
875
|
-
end.compact
|
|
876
|
-
end
|
|
877
|
-
|
|
878
|
-
# Detects local branches with no upstream tracking ref — candidates for orphan pruning.
|
|
879
|
-
# Filters out protected branches, the active branch, and Carson's own sync branch.
|
|
880
|
-
def orphan_local_branches( active_branch: )
|
|
881
|
-
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.filter_map do |line|
|
|
882
|
-
branch, upstream = line.strip.split( "\t", 2 )
|
|
883
|
-
branch = branch.to_s.strip
|
|
884
|
-
upstream = upstream.to_s.strip
|
|
885
|
-
next if branch.empty?
|
|
886
|
-
next unless upstream.empty?
|
|
887
|
-
next if config.protected_branches.include?( branch )
|
|
888
|
-
next if branch == active_branch
|
|
889
|
-
next if branch == TEMPLATE_SYNC_BRANCH
|
|
890
|
-
|
|
891
|
-
branch
|
|
892
|
-
end
|
|
893
|
-
end
|
|
894
|
-
|
|
895
|
-
# Processes orphan branches: verifies merged PR evidence via GitHub API before deleting.
|
|
896
|
-
def prune_orphan_branch_entries( orphan_branches:, counters: )
|
|
897
|
-
return counters if orphan_branches.empty?
|
|
898
|
-
return counters unless gh_available?
|
|
899
|
-
|
|
900
|
-
orphan_branches.each do |branch|
|
|
901
|
-
outcome = prune_orphan_branch_entry( branch: branch )
|
|
902
|
-
counters[ outcome ] += 1
|
|
903
|
-
end
|
|
904
|
-
counters
|
|
905
|
-
end
|
|
906
|
-
|
|
907
|
-
# Checks a single orphan branch for merged PR evidence and force-deletes if confirmed.
|
|
908
|
-
def prune_orphan_branch_entry( branch: )
|
|
909
|
-
tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
|
|
910
|
-
unless tip_sha_success
|
|
911
|
-
error_text = tip_sha_error.to_s.strip
|
|
912
|
-
error_text = "unable to read local branch tip sha" if error_text.empty?
|
|
913
|
-
puts_verbose "skip_orphan_branch: #{branch} reason=#{error_text}"
|
|
914
|
-
return :skipped
|
|
915
|
-
end
|
|
916
|
-
branch_tip_sha = tip_sha_text.to_s.strip
|
|
917
|
-
if branch_tip_sha.empty?
|
|
918
|
-
puts_verbose "skip_orphan_branch: #{branch} reason=unable to read local branch tip sha"
|
|
919
|
-
return :skipped
|
|
920
|
-
end
|
|
921
|
-
|
|
922
|
-
merged_pr, error = merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
|
|
923
|
-
if merged_pr.nil?
|
|
924
|
-
reason = error.to_s.strip
|
|
925
|
-
reason = "no merged PR evidence for branch tip into #{config.main_branch}" if reason.empty?
|
|
926
|
-
puts_verbose "skip_orphan_branch: #{branch} reason=#{reason}"
|
|
927
|
-
return :skipped
|
|
928
|
-
end
|
|
929
|
-
|
|
930
|
-
force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
|
|
931
|
-
if force_success
|
|
932
|
-
out.print force_stdout if verbose? && !force_stdout.empty?
|
|
933
|
-
puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
|
|
934
|
-
return :deleted
|
|
935
|
-
end
|
|
936
|
-
|
|
937
|
-
force_error_text = normalise_branch_delete_error( error_text: force_stderr )
|
|
938
|
-
puts_verbose "fail_delete_orphan_branch: #{branch} reason=#{force_error_text}"
|
|
939
|
-
:skipped
|
|
940
|
-
end
|
|
941
|
-
|
|
942
|
-
# Safe delete can fail after squash merges because branch tip is no longer an ancestor.
|
|
943
|
-
def non_merged_delete_error?( error_text: )
|
|
944
|
-
error_text.to_s.downcase.include?( "not fully merged" )
|
|
945
|
-
end
|
|
946
|
-
|
|
947
|
-
# Guarded force-delete policy for stale branches:
|
|
948
|
-
# 1) safe delete failure must be merge-related (`not fully merged`),
|
|
949
|
-
# 2) gh must confirm at least one merged PR for this exact branch into configured main.
|
|
950
|
-
def force_delete_evidence_for_stale_branch( branch:, delete_error_text: )
|
|
951
|
-
return [ nil, "safe delete failure is not merge-related" ] unless non_merged_delete_error?( error_text: delete_error_text )
|
|
952
|
-
return [ nil, "gh CLI not available; cannot verify merged PR evidence" ] unless gh_available?
|
|
953
|
-
|
|
954
|
-
tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
|
|
955
|
-
unless tip_sha_success
|
|
956
|
-
error_text = tip_sha_error.to_s.strip
|
|
957
|
-
error_text = "unable to read local branch tip sha" if error_text.empty?
|
|
958
|
-
return [ nil, error_text ]
|
|
959
|
-
end
|
|
960
|
-
branch_tip_sha = tip_sha_text.to_s.strip
|
|
961
|
-
return [ nil, "unable to read local branch tip sha" ] if branch_tip_sha.empty?
|
|
962
|
-
|
|
963
|
-
merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
|
|
964
|
-
end
|
|
965
|
-
|
|
966
|
-
# Finds merged PR evidence for the exact local branch tip; this blocks old-PR false positives.
|
|
967
|
-
def merged_pr_for_branch( branch:, branch_tip_sha: )
|
|
968
|
-
owner, repo = repository_coordinates
|
|
969
|
-
results = []
|
|
970
|
-
page = 1
|
|
971
|
-
max_pages = 50
|
|
972
|
-
loop do
|
|
973
|
-
stdout_text, stderr_text, success, = gh_run(
|
|
974
|
-
"api", "repos/#{owner}/#{repo}/pulls",
|
|
975
|
-
"--method", "GET",
|
|
976
|
-
"-f", "state=closed",
|
|
977
|
-
"-f", "base=#{config.main_branch}",
|
|
978
|
-
"-f", "head=#{owner}:#{branch}",
|
|
979
|
-
"-f", "sort=updated",
|
|
980
|
-
"-f", "direction=desc",
|
|
981
|
-
"-f", "per_page=100",
|
|
982
|
-
"-f", "page=#{page}"
|
|
983
|
-
)
|
|
984
|
-
unless success
|
|
985
|
-
error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to query merged PR evidence for branch #{branch}" )
|
|
986
|
-
return [ nil, error_text ]
|
|
987
|
-
end
|
|
988
|
-
page_nodes = Array( JSON.parse( stdout_text ) )
|
|
989
|
-
break if page_nodes.empty?
|
|
990
|
-
|
|
991
|
-
page_nodes.each do |entry|
|
|
992
|
-
next unless entry.dig( "head", "ref" ).to_s == branch.to_s
|
|
993
|
-
next unless entry.dig( "base", "ref" ).to_s == config.main_branch
|
|
994
|
-
next unless entry.dig( "head", "sha" ).to_s == branch_tip_sha
|
|
995
|
-
|
|
996
|
-
merged_at = parse_time_or_nil( text: entry[ "merged_at" ] )
|
|
997
|
-
next if merged_at.nil?
|
|
998
|
-
|
|
999
|
-
results << {
|
|
1000
|
-
number: entry[ "number" ],
|
|
1001
|
-
url: entry[ "html_url" ].to_s,
|
|
1002
|
-
merged_at: merged_at.utc.iso8601,
|
|
1003
|
-
head_sha: entry.dig( "head", "sha" ).to_s
|
|
1004
|
-
}
|
|
1005
|
-
end
|
|
1006
|
-
if page >= max_pages
|
|
1007
|
-
probe_stdout_text, probe_stderr_text, probe_success, = gh_run(
|
|
1008
|
-
"api", "repos/#{owner}/#{repo}/pulls",
|
|
1009
|
-
"--method", "GET",
|
|
1010
|
-
"-f", "state=closed",
|
|
1011
|
-
"-f", "base=#{config.main_branch}",
|
|
1012
|
-
"-f", "head=#{owner}:#{branch}",
|
|
1013
|
-
"-f", "sort=updated",
|
|
1014
|
-
"-f", "direction=desc",
|
|
1015
|
-
"-f", "per_page=100",
|
|
1016
|
-
"-f", "page=#{page + 1}"
|
|
1017
|
-
)
|
|
1018
|
-
unless probe_success
|
|
1019
|
-
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}" )
|
|
1020
|
-
return [ nil, error_text ]
|
|
1021
|
-
end
|
|
1022
|
-
probe_nodes = Array( JSON.parse( probe_stdout_text ) )
|
|
1023
|
-
return [ nil, "merged PR lookup exceeded pagination safety limit (#{max_pages} pages) for branch #{branch}" ] unless probe_nodes.empty?
|
|
1024
|
-
break
|
|
1025
|
-
end
|
|
1026
|
-
page += 1
|
|
1027
|
-
end
|
|
1028
|
-
latest = results.max_by { |item| item.fetch( :merged_at ) }
|
|
1029
|
-
return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
|
|
1030
|
-
|
|
1031
|
-
[ latest, nil ]
|
|
1032
|
-
rescue JSON::ParserError => e
|
|
1033
|
-
[ nil, "invalid gh JSON response (#{e.message})" ]
|
|
1034
|
-
rescue StandardError => e
|
|
1035
|
-
[ nil, e.message ]
|
|
1036
|
-
end
|
|
1037
|
-
|
|
1038
|
-
def working_tree_clean?
|
|
1039
|
-
git_capture!( "status", "--porcelain" ).strip.empty?
|
|
1040
|
-
end
|
|
1041
|
-
|
|
1042
|
-
def push_prep_commit!
|
|
1043
|
-
# JIT auto-commit is for feature branches only; main is protected from direct commits.
|
|
1044
|
-
return if current_branch == config.main_branch
|
|
1045
|
-
|
|
1046
|
-
dirty = managed_dirty_paths
|
|
1047
|
-
return if dirty.empty?
|
|
1048
|
-
|
|
1049
|
-
git_system!( "add", *dirty )
|
|
1050
|
-
git_system!( "commit", "-m", "chore: sync Carson managed files" )
|
|
1051
|
-
puts_line "Carson committed managed file updates. Push again to include them."
|
|
1052
|
-
true
|
|
1053
|
-
end
|
|
1054
|
-
|
|
1055
|
-
def managed_dirty_paths
|
|
1056
|
-
template_paths = config.template_managed_files + config.template_superseded_files
|
|
1057
|
-
linters_glob = Dir.glob( File.join( repo_root, ".github/linters/**/*" ) )
|
|
1058
|
-
.select { |p| File.file?( p ) }
|
|
1059
|
-
.map { |p| p.delete_prefix( "#{repo_root}/" ) }
|
|
1060
|
-
candidates = ( template_paths + linters_glob ).uniq
|
|
1061
|
-
return [] if candidates.empty?
|
|
1062
|
-
|
|
1063
|
-
stdout_text, = git_capture_soft( "status", "--porcelain", "--", *candidates )
|
|
1064
|
-
stdout_text.to_s.lines
|
|
1065
|
-
.map { |l| l[ 3.. ].strip }
|
|
1066
|
-
.reject( &:empty? )
|
|
1067
|
-
end
|
|
1068
|
-
|
|
1069
|
-
def inside_git_work_tree?
|
|
1070
|
-
stdout_text, = git_capture_soft( "rev-parse", "--is-inside-work-tree" )
|
|
1071
|
-
stdout_text.to_s.strip == "true"
|
|
1072
|
-
end
|
|
1073
|
-
|
|
1074
|
-
def disable_carson_hooks_path!
|
|
1075
|
-
configured = configured_hooks_path
|
|
1076
|
-
if configured.nil?
|
|
1077
|
-
puts_verbose "hooks_path: (unset)"
|
|
1078
|
-
return EXIT_OK
|
|
1079
|
-
end
|
|
1080
|
-
puts_verbose "hooks_path: #{configured}"
|
|
1081
|
-
configured_abs = File.expand_path( configured, repo_root )
|
|
1082
|
-
unless carson_managed_hooks_path?( configured_abs: configured_abs )
|
|
1083
|
-
puts_verbose "hooks_path_kept: #{configured} (not Carson-managed)"
|
|
1084
|
-
return EXIT_OK
|
|
1085
|
-
end
|
|
1086
|
-
git_system!( "config", "--unset", "core.hooksPath" )
|
|
1087
|
-
puts_verbose "hooks_path_unset: core.hooksPath"
|
|
1088
|
-
EXIT_OK
|
|
1089
|
-
rescue StandardError => e
|
|
1090
|
-
puts_line "ERROR: unable to update core.hooksPath (#{e.message})"
|
|
1091
|
-
EXIT_ERROR
|
|
1092
|
-
end
|
|
1093
|
-
|
|
1094
|
-
def carson_managed_hooks_path?( configured_abs: )
|
|
1095
|
-
hooks_root = File.join( File.expand_path( config.hooks_base_path ), "" )
|
|
1096
|
-
return true if configured_abs.start_with?( hooks_root )
|
|
1097
|
-
|
|
1098
|
-
carson_hook_files_match_templates?( hooks_path: configured_abs )
|
|
1099
|
-
end
|
|
1100
|
-
|
|
1101
|
-
def carson_hook_files_match_templates?( hooks_path: )
|
|
1102
|
-
return false unless Dir.exist?( hooks_path )
|
|
1103
|
-
config.required_hooks.all? do |hook_name|
|
|
1104
|
-
installed_path = File.join( hooks_path, hook_name )
|
|
1105
|
-
template_path = hook_template_path( hook_name: hook_name )
|
|
1106
|
-
next false unless File.file?( installed_path ) && File.file?( template_path )
|
|
1107
|
-
|
|
1108
|
-
installed_content = normalize_text( text: File.read( installed_path ) )
|
|
1109
|
-
template_content = normalize_text( text: File.read( template_path ) )
|
|
1110
|
-
installed_content == template_content
|
|
1111
|
-
end
|
|
1112
|
-
rescue StandardError
|
|
1113
|
-
false
|
|
1114
|
-
end
|
|
1115
|
-
|
|
1116
|
-
def offboard_cleanup_targets
|
|
1117
|
-
( config.template_managed_files + config.template_superseded_files + [
|
|
1118
|
-
".github/workflows/carson-governance.yml",
|
|
1119
|
-
".github/workflows/carson_policy.yml",
|
|
1120
|
-
".carson.yml",
|
|
1121
|
-
"bin/carson",
|
|
1122
|
-
".tools/carson"
|
|
1123
|
-
] ).uniq
|
|
1124
|
-
end
|
|
1125
|
-
|
|
1126
|
-
def remove_empty_offboard_directories!
|
|
1127
|
-
[ ".github/workflows", ".github", ".tools", "bin" ].each do |relative|
|
|
1128
|
-
absolute = resolve_repo_path!( relative_path: relative, label: "offboard cleanup directory #{relative}" )
|
|
1129
|
-
next unless Dir.exist?( absolute )
|
|
1130
|
-
next unless Dir.empty?( absolute )
|
|
1131
|
-
|
|
1132
|
-
Dir.rmdir( absolute )
|
|
1133
|
-
puts_verbose "removed_empty_dir: #{relative}"
|
|
1134
|
-
end
|
|
1135
|
-
end
|
|
1136
|
-
|
|
1137
|
-
# Verifies configured remote exists and logs status without mutating remotes.
|
|
1138
|
-
def report_detected_remote!
|
|
1139
|
-
if git_remote_exists?( remote_name: config.git_remote )
|
|
1140
|
-
puts_verbose "remote_ok: #{config.git_remote}"
|
|
1141
|
-
else
|
|
1142
|
-
puts_line "WARN: remote '#{config.git_remote}' not found; run carson setup to configure."
|
|
1143
|
-
end
|
|
1144
|
-
end
|
|
1145
|
-
|
|
1146
|
-
# Concise onboard orchestration: hooks, templates, remote, audit, guidance.
|
|
1147
|
-
def onboard_apply!
|
|
1148
|
-
hook_status = with_captured_output { prepare! }
|
|
1149
|
-
return hook_status unless hook_status == EXIT_OK
|
|
1150
|
-
puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
|
|
1151
|
-
|
|
1152
|
-
template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
1153
|
-
template_status = with_captured_output { template_apply! }
|
|
1154
|
-
return template_status unless template_status == EXIT_OK
|
|
1155
|
-
if template_drift_count.positive?
|
|
1156
|
-
puts_line "Templates synced (#{template_drift_count} file#{plural_suffix( count: template_drift_count )} updated)."
|
|
1157
|
-
else
|
|
1158
|
-
puts_line "Templates in sync."
|
|
1159
|
-
end
|
|
1160
|
-
|
|
1161
|
-
onboard_report_remote!
|
|
1162
|
-
audit_status = onboard_run_audit!
|
|
1163
|
-
|
|
1164
|
-
puts_line ""
|
|
1165
|
-
puts_line "Carson at your service."
|
|
1166
|
-
|
|
1167
|
-
prompt_govern_registration! if self.in.respond_to?( :tty? ) && self.in.tty?
|
|
1168
|
-
|
|
1169
|
-
puts_line ""
|
|
1170
|
-
puts_line "Your repository is set up. Carson has placed files in your"
|
|
1171
|
-
puts_line "project's .github/ directory — pull request templates,"
|
|
1172
|
-
puts_line "guidelines for AI coding assistants, and any CI or lint"
|
|
1173
|
-
puts_line "rules you've configured. Once pushed to GitHub, they'll"
|
|
1174
|
-
puts_line "ensure every pull request follows a consistent standard"
|
|
1175
|
-
puts_line "and all checks run automatically."
|
|
1176
|
-
puts_line ""
|
|
1177
|
-
puts_line "Before your first push, have a look through .github/ to"
|
|
1178
|
-
puts_line "make sure everything is to your liking."
|
|
1179
|
-
puts_line ""
|
|
1180
|
-
puts_line "To adjust any setting: carson setup"
|
|
1181
|
-
|
|
1182
|
-
audit_status
|
|
1183
|
-
end
|
|
1184
|
-
|
|
1185
|
-
# Friendly remote status for onboard output.
|
|
1186
|
-
def onboard_report_remote!
|
|
1187
|
-
if git_remote_exists?( remote_name: config.git_remote )
|
|
1188
|
-
puts_line "Remote: #{config.git_remote} (connected)."
|
|
1189
|
-
else
|
|
1190
|
-
puts_line "Remote not configured yet — carson setup will walk you through it."
|
|
1191
|
-
end
|
|
1192
|
-
end
|
|
1193
|
-
|
|
1194
|
-
# Runs audit with captured output; reports summary instead of full detail.
|
|
1195
|
-
def onboard_run_audit!
|
|
1196
|
-
audit_error = nil
|
|
1197
|
-
audit_status = with_captured_output { audit! }
|
|
1198
|
-
rescue StandardError => e
|
|
1199
|
-
audit_error = e
|
|
1200
|
-
audit_status = EXIT_OK
|
|
1201
|
-
ensure
|
|
1202
|
-
return onboard_print_audit_result( status: audit_status, error: audit_error )
|
|
1203
|
-
end
|
|
1204
|
-
|
|
1205
|
-
def onboard_print_audit_result( status:, error: )
|
|
1206
|
-
if error
|
|
1207
|
-
if error.message.to_s.match?( /HEAD|rev-parse/ )
|
|
1208
|
-
puts_line "No commits yet — run carson audit after your first commit."
|
|
1209
|
-
else
|
|
1210
|
-
puts_line "Audit skipped — run carson audit for details."
|
|
1211
|
-
end
|
|
1212
|
-
return EXIT_OK
|
|
1213
|
-
end
|
|
1214
|
-
|
|
1215
|
-
if status == EXIT_BLOCK
|
|
1216
|
-
puts_line "Some checks need attention — run carson audit for details."
|
|
1217
|
-
end
|
|
1218
|
-
status
|
|
1219
|
-
end
|
|
1220
|
-
|
|
1221
|
-
# Uses `git remote get-url` as existence check to avoid parsing remote lists.
|
|
1222
|
-
def git_remote_exists?( remote_name: )
|
|
1223
|
-
_, _, success, = git_run( "remote", "get-url", remote_name.to_s )
|
|
1224
|
-
success
|
|
1225
|
-
end
|
|
1226
|
-
end
|
|
1227
|
-
|
|
1228
9
|
include Local
|
|
1229
10
|
end
|
|
1230
11
|
end
|