carson 1.0.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 +7 -0
- data/.github/copilot-instructions.md +12 -0
- data/.github/pull_request_template.md +14 -0
- data/.github/workflows/carson_policy.yml +90 -0
- data/API.md +114 -0
- data/LICENSE +21 -0
- data/MANUAL.md +170 -0
- data/README.md +48 -0
- data/RELEASE.md +592 -0
- data/VERSION +1 -0
- data/assets/hooks/pre-commit +19 -0
- data/assets/hooks/pre-merge-commit +8 -0
- data/assets/hooks/pre-push +13 -0
- data/assets/hooks/prepare-commit-msg +8 -0
- data/carson.gemspec +37 -0
- data/exe/carson +13 -0
- data/lib/carson/adapters/git.rb +20 -0
- data/lib/carson/adapters/github.rb +20 -0
- data/lib/carson/cli.rb +189 -0
- data/lib/carson/config.rb +348 -0
- data/lib/carson/policy/ruby/lint.rb +61 -0
- data/lib/carson/runtime/audit.rb +793 -0
- data/lib/carson/runtime/lint.rb +177 -0
- data/lib/carson/runtime/local.rb +661 -0
- data/lib/carson/runtime/review/data_access.rb +253 -0
- data/lib/carson/runtime/review/gate_support.rb +224 -0
- data/lib/carson/runtime/review/query_text.rb +164 -0
- data/lib/carson/runtime/review/sweep_support.rb +252 -0
- data/lib/carson/runtime/review/utility.rb +63 -0
- data/lib/carson/runtime/review.rb +182 -0
- data/lib/carson/runtime.rb +182 -0
- data/lib/carson/version.rb +4 -0
- data/lib/carson.rb +6 -0
- data/templates/.github/copilot-instructions.md +12 -0
- data/templates/.github/pull_request_template.md +14 -0
- metadata +80 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
module Carson
|
|
2
|
+
class Runtime
|
|
3
|
+
module Local
|
|
4
|
+
def sync!
|
|
5
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
6
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
7
|
+
|
|
8
|
+
unless working_tree_clean?
|
|
9
|
+
puts_line "BLOCK: working tree is dirty; commit/stash first, then run carson sync."
|
|
10
|
+
return EXIT_BLOCK
|
|
11
|
+
end
|
|
12
|
+
start_branch = current_branch
|
|
13
|
+
switched = false
|
|
14
|
+
git_system!( "fetch", config.git_remote, "--prune" )
|
|
15
|
+
if start_branch != config.main_branch
|
|
16
|
+
git_system!( "switch", config.main_branch )
|
|
17
|
+
switched = true
|
|
18
|
+
end
|
|
19
|
+
git_system!( "pull", "--ff-only", config.git_remote, config.main_branch )
|
|
20
|
+
ahead_count, behind_count, error_text = main_sync_counts
|
|
21
|
+
if error_text
|
|
22
|
+
puts_line "BLOCK: unable to verify main sync state (#{error_text})."
|
|
23
|
+
return EXIT_BLOCK
|
|
24
|
+
end
|
|
25
|
+
if ahead_count.zero? && behind_count.zero?
|
|
26
|
+
puts_line "OK: local #{config.main_branch} is now in sync with #{config.git_remote}/#{config.main_branch}."
|
|
27
|
+
return EXIT_OK
|
|
28
|
+
end
|
|
29
|
+
puts_line "BLOCK: local #{config.main_branch} still diverges (ahead=#{ahead_count}, behind=#{behind_count})."
|
|
30
|
+
EXIT_BLOCK
|
|
31
|
+
ensure
|
|
32
|
+
git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Removes stale local branches that track remote refs already deleted upstream.
|
|
36
|
+
def prune!
|
|
37
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
38
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
39
|
+
|
|
40
|
+
git_system!( "fetch", config.git_remote, "--prune" )
|
|
41
|
+
active_branch = current_branch
|
|
42
|
+
stale_branches = stale_local_branches
|
|
43
|
+
return prune_no_stale_branches if stale_branches.empty?
|
|
44
|
+
|
|
45
|
+
counters = prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch )
|
|
46
|
+
puts_line "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
|
|
47
|
+
EXIT_OK
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def prune_no_stale_branches
|
|
51
|
+
puts_line "OK: no stale local branches tracking deleted #{config.git_remote} branches."
|
|
52
|
+
EXIT_OK
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prune_stale_branch_entries( stale_branches:, active_branch: )
|
|
56
|
+
counters = { deleted: 0, skipped: 0 }
|
|
57
|
+
stale_branches.each do |entry|
|
|
58
|
+
outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
|
|
59
|
+
counters[ outcome ] += 1
|
|
60
|
+
end
|
|
61
|
+
counters
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def prune_stale_branch_entry( entry:, active_branch: )
|
|
65
|
+
branch = entry.fetch( :branch )
|
|
66
|
+
upstream = entry.fetch( :upstream )
|
|
67
|
+
return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
|
|
68
|
+
return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch
|
|
69
|
+
|
|
70
|
+
prune_delete_stale_branch( branch: branch, upstream: upstream )
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def prune_skip_stale_branch( type:, branch:, upstream: )
|
|
74
|
+
status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
|
|
75
|
+
puts_line "#{status}: #{branch} (upstream=#{upstream})"
|
|
76
|
+
:skipped
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def prune_delete_stale_branch( branch:, upstream: )
|
|
80
|
+
stdout_text, stderr_text, success, = git_run( "branch", "-d", branch )
|
|
81
|
+
return prune_safe_delete_success( branch: branch, upstream: upstream, stdout_text: stdout_text ) if success
|
|
82
|
+
|
|
83
|
+
delete_error_text = normalise_branch_delete_error( error_text: stderr_text )
|
|
84
|
+
prune_force_delete_stale_branch(
|
|
85
|
+
branch: branch,
|
|
86
|
+
upstream: upstream,
|
|
87
|
+
delete_error_text: delete_error_text
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def prune_safe_delete_success( branch:, upstream:, stdout_text: )
|
|
92
|
+
out.print stdout_text unless stdout_text.empty?
|
|
93
|
+
puts_line "deleted_local_branch: #{branch} (upstream=#{upstream})"
|
|
94
|
+
:deleted
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
|
|
98
|
+
merged_pr, force_error = force_delete_evidence_for_stale_branch(
|
|
99
|
+
branch: branch,
|
|
100
|
+
delete_error_text: delete_error_text
|
|
101
|
+
)
|
|
102
|
+
return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?
|
|
103
|
+
|
|
104
|
+
force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
|
|
105
|
+
return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success
|
|
106
|
+
|
|
107
|
+
prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
|
|
111
|
+
out.print force_stdout unless force_stdout.empty?
|
|
112
|
+
puts_line "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
|
|
113
|
+
:deleted
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def prune_force_delete_failed( branch:, upstream:, force_stderr: )
|
|
117
|
+
force_error_text = normalise_branch_delete_error( error_text: force_stderr )
|
|
118
|
+
puts_line "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
|
|
119
|
+
:skipped
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
|
|
123
|
+
puts_line "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
|
|
124
|
+
puts_line "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
|
|
125
|
+
:skipped
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalise_branch_delete_error( error_text: )
|
|
129
|
+
text = error_text.to_s.strip
|
|
130
|
+
text.empty? ? "unknown error" : text
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Installs required hook files and enforces repository hook path.
|
|
134
|
+
def hook!
|
|
135
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
136
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
137
|
+
|
|
138
|
+
FileUtils.mkdir_p( hooks_dir )
|
|
139
|
+
missing_templates = config.required_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
|
|
140
|
+
unless missing_templates.empty?
|
|
141
|
+
puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
|
|
142
|
+
return EXIT_BLOCK
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
symlinked = symlink_hook_files
|
|
146
|
+
unless symlinked.empty?
|
|
147
|
+
puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
|
|
148
|
+
return EXIT_BLOCK
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
config.required_hooks.each do |hook_name|
|
|
152
|
+
source_path = hook_template_path( hook_name: hook_name )
|
|
153
|
+
target_path = File.join( hooks_dir, hook_name )
|
|
154
|
+
FileUtils.cp( source_path, target_path )
|
|
155
|
+
FileUtils.chmod( 0o755, target_path )
|
|
156
|
+
puts_line "hook_written: #{relative_path( target_path )}"
|
|
157
|
+
end
|
|
158
|
+
git_system!( "config", "core.hooksPath", hooks_dir )
|
|
159
|
+
puts_line "configured_hooks_path: #{hooks_dir}"
|
|
160
|
+
check!
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# One-command initialisation for new repositories: align remote naming, install hooks,
|
|
164
|
+
# apply templates, and produce a first audit report.
|
|
165
|
+
def init!
|
|
166
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
167
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
168
|
+
|
|
169
|
+
print_header "Init"
|
|
170
|
+
unless inside_git_work_tree?
|
|
171
|
+
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
172
|
+
return EXIT_ERROR
|
|
173
|
+
end
|
|
174
|
+
align_remote_name_for_carson!
|
|
175
|
+
hook_status = hook!
|
|
176
|
+
return hook_status unless hook_status == EXIT_OK
|
|
177
|
+
|
|
178
|
+
template_status = template_apply!
|
|
179
|
+
return template_status unless template_status == EXIT_OK
|
|
180
|
+
|
|
181
|
+
audit_status = audit!
|
|
182
|
+
if audit_status == EXIT_OK
|
|
183
|
+
puts_line "OK: Carson initialisation completed for #{repo_root}."
|
|
184
|
+
elsif audit_status == EXIT_BLOCK
|
|
185
|
+
puts_line "BLOCK: Carson initialisation completed with policy blocks; resolve and rerun carson audit."
|
|
186
|
+
end
|
|
187
|
+
audit_status
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
|
|
191
|
+
def offboard!
|
|
192
|
+
print_header "Offboard"
|
|
193
|
+
unless inside_git_work_tree?
|
|
194
|
+
puts_line "ERROR: #{repo_root} is not a git repository."
|
|
195
|
+
return EXIT_ERROR
|
|
196
|
+
end
|
|
197
|
+
hooks_status = disable_carson_hooks_path!
|
|
198
|
+
return hooks_status unless hooks_status == EXIT_OK
|
|
199
|
+
|
|
200
|
+
removed_count = 0
|
|
201
|
+
missing_count = 0
|
|
202
|
+
offboard_cleanup_targets.each do |relative|
|
|
203
|
+
absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" )
|
|
204
|
+
if File.exist?( absolute )
|
|
205
|
+
FileUtils.rm_rf( absolute )
|
|
206
|
+
puts_line "removed_path: #{relative}"
|
|
207
|
+
removed_count += 1
|
|
208
|
+
else
|
|
209
|
+
puts_line "skip_missing_path: #{relative}"
|
|
210
|
+
missing_count += 1
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
remove_empty_offboard_directories!
|
|
214
|
+
puts_line "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
|
|
215
|
+
puts_line "OK: Carson offboard completed for #{repo_root}."
|
|
216
|
+
EXIT_OK
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Strict hook health check used by humans, hooks, and CI paths.
|
|
220
|
+
def check!
|
|
221
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
222
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
223
|
+
|
|
224
|
+
print_header "Hooks Check"
|
|
225
|
+
ok = hooks_health_report( strict: true )
|
|
226
|
+
puts_line( ok ? "status: ok" : "status: block" )
|
|
227
|
+
ok ? EXIT_OK : EXIT_BLOCK
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Read-only template drift check; returns block when managed files are out of sync.
|
|
231
|
+
def template_check!
|
|
232
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
233
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
234
|
+
|
|
235
|
+
print_header "Template Sync Check"
|
|
236
|
+
results = template_results
|
|
237
|
+
drift_count = results.count { |entry| entry.fetch( :status ) == "drift" }
|
|
238
|
+
error_count = results.count { |entry| entry.fetch( :status ) == "error" }
|
|
239
|
+
results.each do |entry|
|
|
240
|
+
puts_line "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
|
|
241
|
+
end
|
|
242
|
+
puts_line "template_summary: total=#{results.count} drift=#{drift_count} error=#{error_count}"
|
|
243
|
+
return EXIT_ERROR if error_count.positive?
|
|
244
|
+
|
|
245
|
+
drift_count.positive? ? EXIT_BLOCK : EXIT_OK
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Applies managed template files as full-file writes from Carson sources.
|
|
249
|
+
def template_apply!
|
|
250
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
251
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
252
|
+
|
|
253
|
+
print_header "Template Sync Apply"
|
|
254
|
+
results = template_results
|
|
255
|
+
applied = 0
|
|
256
|
+
results.each do |entry|
|
|
257
|
+
if entry.fetch( :status ) == "error"
|
|
258
|
+
puts_line "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}"
|
|
259
|
+
next
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
file_path = File.join( repo_root, entry.fetch( :file ) )
|
|
263
|
+
if entry.fetch( :status ) == "ok"
|
|
264
|
+
puts_line "template_file: #{entry.fetch( :file )} status=ok reason=in_sync"
|
|
265
|
+
next
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
FileUtils.mkdir_p( File.dirname( file_path ) )
|
|
269
|
+
File.write( file_path, entry.fetch( :applied_content ) )
|
|
270
|
+
puts_line "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}"
|
|
271
|
+
applied += 1
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
error_count = results.count { |entry| entry.fetch( :status ) == "error" }
|
|
275
|
+
puts_line "template_apply_summary: updated=#{applied} error=#{error_count}"
|
|
276
|
+
error_count.positive? ? EXIT_ERROR : EXIT_OK
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def template_results
|
|
282
|
+
config.template_managed_files.map { |managed_file| template_result_for_file( managed_file: managed_file ) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Calculates whole-file expected content and returns sync status plus apply payload.
|
|
286
|
+
def template_result_for_file( managed_file: )
|
|
287
|
+
template_path = File.join( github_templates_dir, File.basename( managed_file ) )
|
|
288
|
+
return { file: managed_file, status: "error", reason: "missing template #{File.basename( managed_file )}", applied_content: nil } unless File.file?( template_path )
|
|
289
|
+
|
|
290
|
+
expected_content = normalize_text( text: File.read( template_path ) )
|
|
291
|
+
file_path = resolve_repo_path!( relative_path: managed_file, label: "template.managed_files entry #{managed_file}" )
|
|
292
|
+
return { file: managed_file, status: "drift", reason: "missing_file", applied_content: expected_content } unless File.file?( file_path )
|
|
293
|
+
|
|
294
|
+
current_content = normalize_text( text: File.read( file_path ) )
|
|
295
|
+
return { file: managed_file, status: "ok", reason: "in_sync", applied_content: current_content } if current_content == expected_content
|
|
296
|
+
|
|
297
|
+
{ file: managed_file, status: "drift", reason: "content_mismatch", applied_content: expected_content }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Uses LF-only normalisation so platform newlines do not cause false drift.
|
|
301
|
+
def normalize_text( text: )
|
|
302
|
+
"#{text.to_s.gsub( "\r\n", "\n" ).rstrip}\n"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# GitHub managed template source directory inside Carson repository.
|
|
306
|
+
def github_templates_dir
|
|
307
|
+
File.join( tool_root, "templates", ".github" )
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Canonical hook template location inside Carson repository.
|
|
311
|
+
def hook_template_path( hook_name: )
|
|
312
|
+
File.join( tool_root, "assets", "hooks", hook_name )
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Reports full hook health and can enforce stricter action messaging in `check`.
|
|
316
|
+
def hooks_health_report( strict: false )
|
|
317
|
+
configured = configured_hooks_path
|
|
318
|
+
expected = hooks_dir
|
|
319
|
+
hooks_path_ok = print_hooks_path_status( configured: configured, expected: expected )
|
|
320
|
+
print_required_hook_status
|
|
321
|
+
hooks_integrity = hook_integrity_state
|
|
322
|
+
hooks_ok = hooks_integrity_ok?( hooks_integrity: hooks_integrity )
|
|
323
|
+
print_hook_action(
|
|
324
|
+
strict: strict,
|
|
325
|
+
hooks_ok: hooks_path_ok && hooks_ok,
|
|
326
|
+
hooks_path_ok: hooks_path_ok,
|
|
327
|
+
configured: configured,
|
|
328
|
+
expected: expected
|
|
329
|
+
)
|
|
330
|
+
hooks_path_ok && hooks_ok
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def print_hooks_path_status( configured:, expected: )
|
|
334
|
+
configured_abs = configured.nil? ? nil : File.expand_path( configured )
|
|
335
|
+
hooks_path_ok = configured_abs == expected
|
|
336
|
+
puts_line "hooks_path: #{configured || '(unset)'}"
|
|
337
|
+
puts_line "hooks_path_expected: #{expected}"
|
|
338
|
+
puts_line( hooks_path_ok ? "hooks_path_status: ok" : "hooks_path_status: attention" )
|
|
339
|
+
hooks_path_ok
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def print_required_hook_status
|
|
343
|
+
required_hook_paths.each do |path|
|
|
344
|
+
exists = File.file?( path )
|
|
345
|
+
symlink = File.symlink?( path )
|
|
346
|
+
executable = exists && !symlink && File.executable?( path )
|
|
347
|
+
puts_line "hook_file: #{relative_path( path )} exists=#{exists} symlink=#{symlink} executable=#{executable}"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def hook_integrity_state
|
|
352
|
+
{
|
|
353
|
+
missing: missing_hook_files,
|
|
354
|
+
non_executable: non_executable_hook_files,
|
|
355
|
+
symlinked: symlink_hook_files
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def hooks_integrity_ok?( hooks_integrity: )
|
|
360
|
+
missing_ok = hooks_integrity.fetch( :missing ).empty?
|
|
361
|
+
non_executable_ok = hooks_integrity.fetch( :non_executable ).empty?
|
|
362
|
+
symlinked_ok = hooks_integrity.fetch( :symlinked ).empty?
|
|
363
|
+
missing_ok && non_executable_ok && symlinked_ok
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def print_hook_action( strict:, hooks_ok:, hooks_path_ok:, configured:, expected: )
|
|
367
|
+
return if hooks_ok
|
|
368
|
+
|
|
369
|
+
if strict && !hooks_path_ok
|
|
370
|
+
configured_text = configured.to_s.strip
|
|
371
|
+
if configured_text.empty?
|
|
372
|
+
puts_line "ACTION: hooks path is unset (expected=#{expected})."
|
|
373
|
+
else
|
|
374
|
+
puts_line "ACTION: hooks path mismatch (configured=#{configured_text}, expected=#{expected})."
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
message = strict ? "ACTION: run carson hook to align hooks with Carson #{Carson::VERSION}." : "ACTION: run carson hook to enforce local main protections."
|
|
378
|
+
puts_line message
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Returns ahead/behind counts for local main versus configured remote main.
|
|
382
|
+
def main_sync_counts
|
|
383
|
+
target = "#{config.main_branch}...#{config.git_remote}/#{config.main_branch}"
|
|
384
|
+
stdout_text, stderr_text, success, = git_run( "rev-list", "--left-right", "--count", target )
|
|
385
|
+
unless success
|
|
386
|
+
error_text = stderr_text.to_s.strip
|
|
387
|
+
error_text = "git rev-list failed" if error_text.empty?
|
|
388
|
+
return [ 0, 0, error_text ]
|
|
389
|
+
end
|
|
390
|
+
counts = stdout_text.to_s.strip.split( /\s+/ )
|
|
391
|
+
return [ 0, 0, "unexpected rev-list output: #{stdout_text.to_s.strip}" ] if counts.length < 2
|
|
392
|
+
|
|
393
|
+
[ counts[ 0 ].to_i, counts[ 1 ].to_i, nil ]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Reads configured core.hooksPath and normalises empty values to nil.
|
|
397
|
+
def configured_hooks_path
|
|
398
|
+
stdout_text, = git_capture_soft( "config", "--get", "core.hooksPath" )
|
|
399
|
+
value = stdout_text.to_s.strip
|
|
400
|
+
value.empty? ? nil : value
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Fully-qualified required hook file locations in the target repository.
|
|
404
|
+
def required_hook_paths
|
|
405
|
+
config.required_hooks.map { |name| File.join( hooks_dir, name ) }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Missing required hook files.
|
|
409
|
+
def missing_hook_files
|
|
410
|
+
required_hook_paths.reject { |path| File.file?( path ) }.map { |path| relative_path( path ) }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Required hook files that exist but are not executable.
|
|
414
|
+
def non_executable_hook_files
|
|
415
|
+
required_hook_paths.select { |path| File.file?( path ) && !File.executable?( path ) }.map { |path| relative_path( path ) }
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Symlink hooks are disallowed to prevent bypassing managed hook content.
|
|
419
|
+
def symlink_hook_files
|
|
420
|
+
required_hook_paths.select { |path| File.symlink?( path ) }.map { |path| relative_path( path ) }
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Local directory where managed hooks are installed.
|
|
424
|
+
def hooks_dir
|
|
425
|
+
File.expand_path( File.join( config.hooks_base_path, Carson::VERSION ) )
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# In outsider mode, Carson must not leave Carson-owned fingerprints in host repositories.
|
|
429
|
+
def block_if_outsider_fingerprints!
|
|
430
|
+
return nil unless outsider_mode?
|
|
431
|
+
|
|
432
|
+
violations = outsider_fingerprint_violations
|
|
433
|
+
return nil if violations.empty?
|
|
434
|
+
|
|
435
|
+
violations.each { |entry| puts_line "BLOCK: #{entry}" }
|
|
436
|
+
EXIT_BLOCK
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Carson source repository itself is excluded from host-repository fingerprint checks.
|
|
440
|
+
def outsider_mode?
|
|
441
|
+
File.expand_path( repo_root ) != File.expand_path( tool_root )
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Detects Carson-owned host artefacts that violate outsider boundary.
|
|
445
|
+
def outsider_fingerprint_violations
|
|
446
|
+
violations = []
|
|
447
|
+
violations << "forbidden file .carson.yml detected" if File.file?( File.join( repo_root, ".carson.yml" ) )
|
|
448
|
+
violations << "forbidden file bin/carson detected" if File.file?( File.join( repo_root, "bin", "carson" ) )
|
|
449
|
+
violations << "forbidden directory .tools/carson detected" if Dir.exist?( File.join( repo_root, ".tools", "carson" ) )
|
|
450
|
+
violations
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# NOTE: prune only targets local branches that meet both conditions:
|
|
454
|
+
# 1) branch tracks configured remote (`github/*` by default), and
|
|
455
|
+
# 2) upstream tracking state is marked as gone after fetch --prune.
|
|
456
|
+
# Branches without upstream tracking are intentionally excluded.
|
|
457
|
+
def stale_local_branches
|
|
458
|
+
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
|
|
459
|
+
branch, upstream, track = line.strip.split( "\t", 3 )
|
|
460
|
+
upstream = upstream.to_s
|
|
461
|
+
track = track.to_s
|
|
462
|
+
next if branch.to_s.empty? || upstream.empty?
|
|
463
|
+
next unless upstream.start_with?( "#{config.git_remote}/" ) && track.include?( "gone" )
|
|
464
|
+
|
|
465
|
+
{ branch: branch, upstream: upstream, track: track }
|
|
466
|
+
end.compact
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Safe delete can fail after squash merges because branch tip is no longer an ancestor.
|
|
470
|
+
def non_merged_delete_error?( error_text: )
|
|
471
|
+
error_text.to_s.downcase.include?( "not fully merged" )
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Guarded force-delete policy for stale branches:
|
|
475
|
+
# 1) safe delete failure must be merge-related (`not fully merged`),
|
|
476
|
+
# 2) gh must confirm at least one merged PR for this exact branch into configured main.
|
|
477
|
+
def force_delete_evidence_for_stale_branch( branch:, delete_error_text: )
|
|
478
|
+
return [ nil, "safe delete failure is not merge-related" ] unless non_merged_delete_error?( error_text: delete_error_text )
|
|
479
|
+
return [ nil, "gh CLI not available; cannot verify merged PR evidence" ] unless gh_available?
|
|
480
|
+
|
|
481
|
+
tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
|
|
482
|
+
unless tip_sha_success
|
|
483
|
+
error_text = tip_sha_error.to_s.strip
|
|
484
|
+
error_text = "unable to read local branch tip sha" if error_text.empty?
|
|
485
|
+
return [ nil, error_text ]
|
|
486
|
+
end
|
|
487
|
+
branch_tip_sha = tip_sha_text.to_s.strip
|
|
488
|
+
return [ nil, "unable to read local branch tip sha" ] if branch_tip_sha.empty?
|
|
489
|
+
|
|
490
|
+
merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Finds merged PR evidence for the exact local branch tip; this blocks old-PR false positives.
|
|
494
|
+
def merged_pr_for_branch( branch:, branch_tip_sha: )
|
|
495
|
+
owner, repo = repository_coordinates
|
|
496
|
+
results = []
|
|
497
|
+
page = 1
|
|
498
|
+
max_pages = 50
|
|
499
|
+
loop do
|
|
500
|
+
stdout_text, stderr_text, success, = gh_run(
|
|
501
|
+
"api", "repos/#{owner}/#{repo}/pulls",
|
|
502
|
+
"--method", "GET",
|
|
503
|
+
"-f", "state=closed",
|
|
504
|
+
"-f", "base=#{config.main_branch}",
|
|
505
|
+
"-f", "head=#{owner}:#{branch}",
|
|
506
|
+
"-f", "sort=updated",
|
|
507
|
+
"-f", "direction=desc",
|
|
508
|
+
"-f", "per_page=100",
|
|
509
|
+
"-f", "page=#{page}"
|
|
510
|
+
)
|
|
511
|
+
unless success
|
|
512
|
+
error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to query merged PR evidence for branch #{branch}" )
|
|
513
|
+
return [ nil, error_text ]
|
|
514
|
+
end
|
|
515
|
+
page_nodes = Array( JSON.parse( stdout_text ) )
|
|
516
|
+
break if page_nodes.empty?
|
|
517
|
+
|
|
518
|
+
page_nodes.each do |entry|
|
|
519
|
+
next unless entry.dig( "head", "ref" ).to_s == branch.to_s
|
|
520
|
+
next unless entry.dig( "base", "ref" ).to_s == config.main_branch
|
|
521
|
+
next unless entry.dig( "head", "sha" ).to_s == branch_tip_sha
|
|
522
|
+
|
|
523
|
+
merged_at = parse_time_or_nil( text: entry[ "merged_at" ] )
|
|
524
|
+
next if merged_at.nil?
|
|
525
|
+
|
|
526
|
+
results << {
|
|
527
|
+
number: entry[ "number" ],
|
|
528
|
+
url: entry[ "html_url" ].to_s,
|
|
529
|
+
merged_at: merged_at.utc.iso8601,
|
|
530
|
+
head_sha: entry.dig( "head", "sha" ).to_s
|
|
531
|
+
}
|
|
532
|
+
end
|
|
533
|
+
if page >= max_pages
|
|
534
|
+
probe_stdout_text, probe_stderr_text, probe_success, = gh_run(
|
|
535
|
+
"api", "repos/#{owner}/#{repo}/pulls",
|
|
536
|
+
"--method", "GET",
|
|
537
|
+
"-f", "state=closed",
|
|
538
|
+
"-f", "base=#{config.main_branch}",
|
|
539
|
+
"-f", "head=#{owner}:#{branch}",
|
|
540
|
+
"-f", "sort=updated",
|
|
541
|
+
"-f", "direction=desc",
|
|
542
|
+
"-f", "per_page=100",
|
|
543
|
+
"-f", "page=#{page + 1}"
|
|
544
|
+
)
|
|
545
|
+
unless probe_success
|
|
546
|
+
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}" )
|
|
547
|
+
return [ nil, error_text ]
|
|
548
|
+
end
|
|
549
|
+
probe_nodes = Array( JSON.parse( probe_stdout_text ) )
|
|
550
|
+
return [ nil, "merged PR lookup exceeded pagination safety limit (#{max_pages} pages) for branch #{branch}" ] unless probe_nodes.empty?
|
|
551
|
+
break
|
|
552
|
+
end
|
|
553
|
+
page += 1
|
|
554
|
+
end
|
|
555
|
+
latest = results.max_by { |item| item.fetch( :merged_at ) }
|
|
556
|
+
return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
|
|
557
|
+
|
|
558
|
+
[ latest, nil ]
|
|
559
|
+
rescue JSON::ParserError => e
|
|
560
|
+
[ nil, "invalid gh JSON response (#{e.message})" ]
|
|
561
|
+
rescue StandardError => e
|
|
562
|
+
[ nil, e.message ]
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def working_tree_clean?
|
|
566
|
+
git_capture!( "status", "--porcelain" ).strip.empty?
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def inside_git_work_tree?
|
|
570
|
+
stdout_text, = git_capture_soft( "rev-parse", "--is-inside-work-tree" )
|
|
571
|
+
stdout_text.to_s.strip == "true"
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def disable_carson_hooks_path!
|
|
575
|
+
configured = configured_hooks_path
|
|
576
|
+
if configured.nil?
|
|
577
|
+
puts_line "hooks_path: (unset)"
|
|
578
|
+
return EXIT_OK
|
|
579
|
+
end
|
|
580
|
+
puts_line "hooks_path: #{configured}"
|
|
581
|
+
configured_abs = File.expand_path( configured, repo_root )
|
|
582
|
+
unless carson_managed_hooks_path?( configured_abs: configured_abs )
|
|
583
|
+
puts_line "hooks_path_kept: #{configured} (not Carson-managed)"
|
|
584
|
+
return EXIT_OK
|
|
585
|
+
end
|
|
586
|
+
git_system!( "config", "--unset", "core.hooksPath" )
|
|
587
|
+
puts_line "hooks_path_unset: core.hooksPath"
|
|
588
|
+
EXIT_OK
|
|
589
|
+
rescue StandardError => e
|
|
590
|
+
puts_line "ERROR: unable to update core.hooksPath (#{e.message})"
|
|
591
|
+
EXIT_ERROR
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def carson_managed_hooks_path?( configured_abs: )
|
|
595
|
+
hooks_root = File.join( File.expand_path( config.hooks_base_path ), "" )
|
|
596
|
+
return true if configured_abs.start_with?( hooks_root )
|
|
597
|
+
|
|
598
|
+
carson_hook_files_match_templates?( hooks_path: configured_abs )
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def carson_hook_files_match_templates?( hooks_path: )
|
|
602
|
+
return false unless Dir.exist?( hooks_path )
|
|
603
|
+
config.required_hooks.all? do |hook_name|
|
|
604
|
+
installed_path = File.join( hooks_path, hook_name )
|
|
605
|
+
template_path = hook_template_path( hook_name: hook_name )
|
|
606
|
+
next false unless File.file?( installed_path ) && File.file?( template_path )
|
|
607
|
+
|
|
608
|
+
installed_content = normalize_text( text: File.read( installed_path ) )
|
|
609
|
+
template_content = normalize_text( text: File.read( template_path ) )
|
|
610
|
+
installed_content == template_content
|
|
611
|
+
end
|
|
612
|
+
rescue StandardError
|
|
613
|
+
false
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def offboard_cleanup_targets
|
|
617
|
+
( config.template_managed_files + [
|
|
618
|
+
".github/workflows/carson-governance.yml",
|
|
619
|
+
".github/workflows/carson_policy.yml",
|
|
620
|
+
".carson.yml",
|
|
621
|
+
"bin/carson",
|
|
622
|
+
".tools/carson"
|
|
623
|
+
] ).uniq
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def remove_empty_offboard_directories!
|
|
627
|
+
[ ".github/workflows", ".github", ".tools", "bin" ].each do |relative|
|
|
628
|
+
absolute = resolve_repo_path!( relative_path: relative, label: "offboard cleanup directory #{relative}" )
|
|
629
|
+
next unless Dir.exist?( absolute )
|
|
630
|
+
next unless Dir.empty?( absolute )
|
|
631
|
+
|
|
632
|
+
Dir.rmdir( absolute )
|
|
633
|
+
puts_line "removed_empty_dir: #{relative}"
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Ensures Carson expected remote naming (`github`) while keeping existing
|
|
638
|
+
# repositories safe when neither `github` nor `origin` exists.
|
|
639
|
+
def align_remote_name_for_carson!
|
|
640
|
+
if git_remote_exists?( remote_name: config.git_remote )
|
|
641
|
+
puts_line "remote_ok: #{config.git_remote}"
|
|
642
|
+
return
|
|
643
|
+
end
|
|
644
|
+
if git_remote_exists?( remote_name: "origin" )
|
|
645
|
+
git_system!( "remote", "rename", "origin", config.git_remote )
|
|
646
|
+
puts_line "remote_renamed: origin -> #{config.git_remote}"
|
|
647
|
+
return
|
|
648
|
+
end
|
|
649
|
+
puts_line "WARN: no #{config.git_remote} or origin remote configured; continue with local baseline only."
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Uses `git remote get-url` as existence check to avoid parsing remote lists.
|
|
653
|
+
def git_remote_exists?( remote_name: )
|
|
654
|
+
_, _, success, = git_run( "remote", "get-url", remote_name.to_s )
|
|
655
|
+
success
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
include Local
|
|
660
|
+
end
|
|
661
|
+
end
|