Module: Carson::Runtime::Local

Included in:
Carson::Runtime
Defined in:
lib/carson/runtime/local.rb

Constant Summary collapse

TEMPLATE_SYNC_BRANCH =
"carson/template-sync".freeze

Instance Method Summary collapse

Instance Method Details

#inspect!Object

Strict hook health check used by humans, hooks, and CI paths.



329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/carson/runtime/local.rb', line 329

def inspect!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	puts_verbose ""
	puts_verbose "[Inspect]"
	ok = hooks_health_report( strict: true )
	puts_verbose( ok ? "status: ok" : "status: block" )
	unless verbose?
		puts_line( ok ? "Hooks: ok" : "Hooks: block" )
	end
	ok ? EXIT_OK : EXIT_BLOCK
end

#normalise_branch_delete_error(error_text:) ⇒ Object



137
138
139
140
# File 'lib/carson/runtime/local.rb', line 137

def normalise_branch_delete_error( error_text: )
	text = error_text.to_s.strip
	text.empty? ? "unknown error" : text
end

#offboard!Object

Removes Carson-managed repository integration so a host repository can retire Carson cleanly.



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/carson/runtime/local.rb', line 293

def offboard!
	puts_verbose ""
	puts_verbose "[Offboard]"
	unless inside_git_work_tree?
		puts_line "ERROR: #{repo_root} is not a git repository."
		return EXIT_ERROR
	end
	hooks_status = disable_carson_hooks_path!
	return hooks_status unless hooks_status == EXIT_OK

	removed_count = 0
	missing_count = 0
	offboard_cleanup_targets.each do |relative|
		absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" )
		if File.exist?( absolute )
			FileUtils.rm_rf( absolute )
			puts_verbose "removed_path: #{relative}"
			removed_count += 1
		else
			puts_verbose "skip_missing_path: #{relative}"
			missing_count += 1
		end
	end
	remove_empty_offboard_directories!
	remove_govern_repo!( repo_path: File.expand_path( repo_root ) )
	puts_verbose "govern_deregistered: #{File.expand_path( repo_root )}"
	puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
	if verbose?
		puts_line "OK: Carson offboard completed for #{repo_root}."
	else
		puts_line "Removed #{removed_count} file#{plural_suffix( count: removed_count )}. Offboard complete."
	end
	EXIT_OK
end

#onboard!Object

One-command onboarding for new repositories: detect remote, install hooks, apply templates, and run initial audit.



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/carson/runtime/local.rb', line 180

def onboard!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	unless inside_git_work_tree?
		puts_line "ERROR: #{repo_root} is not a git repository."
		return EXIT_ERROR
	end

	repo_name = File.basename( repo_root )
	puts_line ""
	puts_line "Onboarding #{repo_name}..."

	if !global_config_exists? || !git_remote_exists?( remote_name: config.git_remote )
		if self.in.respond_to?( :tty? ) && self.in.tty?
			setup_status = setup!
			return setup_status unless setup_status == EXIT_OK
		else
			silent_setup!
		end
	end

	onboard_apply!
end

#prepare!Object

Installs required hook files and enforces repository hook path.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/carson/runtime/local.rb', line 143

def prepare!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	FileUtils.mkdir_p( hooks_dir )
	missing_templates = config.required_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
	unless missing_templates.empty?
		puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
		return EXIT_BLOCK
	end

	symlinked = symlink_hook_files
	unless symlinked.empty?
		puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
		return EXIT_BLOCK
	end

	config.required_hooks.each do |hook_name|
		source_path = hook_template_path( hook_name: hook_name )
		target_path = File.join( hooks_dir, hook_name )
		FileUtils.cp( source_path, target_path )
		FileUtils.chmod( 0o755, target_path )
		puts_verbose "hook_written: #{relative_path( target_path )}"
	end
	git_system!( "config", "core.hooksPath", hooks_dir )
	File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
	puts_verbose "configured_hooks_path: #{hooks_dir}"
	unless verbose?
		puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
		return EXIT_OK
	end

	inspect!
end

#prune!Object

Removes stale local branches that track remote refs already deleted upstream.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/carson/runtime/local.rb', line 38

def prune!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	git_system!( "fetch", config.git_remote, "--prune" )
	active_branch = current_branch
	stale_branches = stale_local_branches
	return prune_no_stale_branches if stale_branches.empty?

	counters = prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch )
	puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
	unless verbose?
		puts_line "Pruned #{counters.fetch( :deleted )} stale branch#{plural_suffix( count: counters.fetch( :deleted ) )}."
	end
	EXIT_OK
end

#prune_delete_stale_branch(branch:, upstream:) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/carson/runtime/local.rb', line 88

def prune_delete_stale_branch( branch:, upstream: )
	stdout_text, stderr_text, success, = git_run( "branch", "-d", branch )
	return prune_safe_delete_success( branch: branch, upstream: upstream, stdout_text: stdout_text ) if success

	delete_error_text = normalise_branch_delete_error( error_text: stderr_text )
	prune_force_delete_stale_branch(
		branch: branch,
		upstream: upstream,
		delete_error_text: delete_error_text
	)
end

#prune_force_delete_failed(branch:, upstream:, force_stderr:) ⇒ Object



125
126
127
128
129
# File 'lib/carson/runtime/local.rb', line 125

def prune_force_delete_failed( branch:, upstream:, force_stderr: )
	force_error_text = normalise_branch_delete_error( error_text: force_stderr )
	puts_verbose "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
	:skipped
end

#prune_force_delete_skipped(branch:, upstream:, delete_error_text:, force_error:) ⇒ Object



131
132
133
134
135
# File 'lib/carson/runtime/local.rb', line 131

def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
	puts_verbose "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
	puts_verbose "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
	:skipped
end

#prune_force_delete_stale_branch(branch:, upstream:, delete_error_text:) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/carson/runtime/local.rb', line 106

def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
	merged_pr, force_error = force_delete_evidence_for_stale_branch(
		branch: branch,
		delete_error_text: delete_error_text
	)
	return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?

	force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
	return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success

	prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
end

#prune_force_delete_success(branch:, upstream:, merged_pr:, force_stdout:) ⇒ Object



119
120
121
122
123
# File 'lib/carson/runtime/local.rb', line 119

def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
	out.print force_stdout if verbose? && !force_stdout.empty?
	puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
	:deleted
end

#prune_no_stale_branchesObject



55
56
57
58
59
60
61
62
# File 'lib/carson/runtime/local.rb', line 55

def prune_no_stale_branches
	if verbose?
		puts_line "OK: no stale local branches tracking deleted #{config.git_remote} branches."
	else
		puts_line "No stale branches."
	end
	EXIT_OK
end

#prune_safe_delete_success(branch:, upstream:, stdout_text:) ⇒ Object



100
101
102
103
104
# File 'lib/carson/runtime/local.rb', line 100

def prune_safe_delete_success( branch:, upstream:, stdout_text: )
	out.print stdout_text if verbose? && !stdout_text.empty?
	puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
	:deleted
end

#prune_skip_stale_branch(type:, branch:, upstream:) ⇒ Object



82
83
84
85
86
# File 'lib/carson/runtime/local.rb', line 82

def prune_skip_stale_branch( type:, branch:, upstream: )
	status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
	puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
	:skipped
end

#prune_stale_branch_entries(stale_branches:, active_branch:) ⇒ Object



64
65
66
67
68
69
70
71
# File 'lib/carson/runtime/local.rb', line 64

def prune_stale_branch_entries( stale_branches:, active_branch: )
	counters = { deleted: 0, skipped: 0 }
	stale_branches.each do |entry|
		outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
		counters[ outcome ] += 1
	end
	counters
end

#prune_stale_branch_entry(entry:, active_branch:) ⇒ Object



73
74
75
76
77
78
79
80
# File 'lib/carson/runtime/local.rb', line 73

def prune_stale_branch_entry( entry:, active_branch: )
	branch = entry.fetch( :branch )
	upstream = entry.fetch( :upstream )
	return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
	return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch

	prune_delete_stale_branch( branch: branch, upstream: upstream )
end

#refresh!Object

Re-applies hooks, templates, and audit after upgrading Carson.



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/carson/runtime/local.rb', line 206

def refresh!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	unless inside_git_work_tree?
		puts_line "ERROR: #{repo_root} is not a git repository."
		return EXIT_ERROR
	end

	if verbose?
		puts_verbose ""
		puts_verbose "[Refresh]"
		hook_status = prepare!
		return hook_status unless hook_status == EXIT_OK

		drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
		template_status = template_apply!
		return template_status unless template_status == EXIT_OK

		@template_sync_result = template_propagate!( drift_count: drift_count )

		audit_status = audit!
		if audit_status == EXIT_OK
			puts_line "OK: Carson refresh completed for #{repo_root}."
		elsif audit_status == EXIT_BLOCK
			puts_line "BLOCK: Carson refresh completed with policy blocks; resolve and rerun carson audit."
		end
		return audit_status
	end

	puts_line "Refresh"
	hook_status = with_captured_output { prepare! }
	return hook_status unless hook_status == EXIT_OK
	puts_line "Hooks installed (#{config.required_hooks.count} hooks)."

	template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
	template_status = with_captured_output { template_apply! }
	return template_status unless template_status == EXIT_OK
	if template_drift_count.positive?
		puts_line "Templates applied (#{template_drift_count} updated)."
	else
		puts_line "Templates in sync."
	end

	@template_sync_result = template_propagate!( drift_count: template_drift_count )

	audit_status = audit!
	puts_line "Refresh complete."
	audit_status
end

#refresh_all!Object

Re-applies hooks, templates, and audit across all governed repositories.



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/carson/runtime/local.rb', line 258

def refresh_all!
	repos = config.govern_repos
	if repos.empty?
		puts_line "No governed repositories configured."
		puts_line "  Run carson onboard in each repo to register."
		return EXIT_ERROR
	end

	puts_line ""
	puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
	refreshed = 0
	failed = 0

	repos.each do |repo_path|
		repo_name = File.basename( repo_path )
		unless Dir.exist?( repo_path )
			puts_line "#{repo_name}: FAIL (path not found)"
			failed += 1
			next
		end

		status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
		if status == EXIT_ERROR
			failed += 1
		else
			refreshed += 1
		end
	end

	puts_line ""
	puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
	failed.zero? ? EXIT_OK : EXIT_ERROR
end

#sync!Object



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/carson/runtime/local.rb', line 6

def sync!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	unless working_tree_clean?
		puts_line "BLOCK: working tree is dirty; commit/stash first, then run carson sync."
		return EXIT_BLOCK
	end
	start_branch = current_branch
	switched = false
	git_system!( "fetch", config.git_remote, "--prune" )
	if start_branch != config.main_branch
		git_system!( "switch", config.main_branch )
		switched = true
	end
	git_system!( "pull", "--ff-only", config.git_remote, config.main_branch )
	ahead_count, behind_count, error_text = main_sync_counts
	if error_text
		puts_line "BLOCK: unable to verify main sync state (#{error_text})."
		return EXIT_BLOCK
	end
	if ahead_count.zero? && behind_count.zero?
		puts_line "OK: local #{config.main_branch} is now in sync with #{config.git_remote}/#{config.main_branch}."
		return EXIT_OK
	end
	puts_line "BLOCK: local #{config.main_branch} still diverges (ahead=#{ahead_count}, behind=#{behind_count})."
	EXIT_BLOCK
ensure
	git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
end

#template_apply!(push_prep: false) ⇒ Object

Applies managed template files as full-file writes from Carson sources. Also removes superseded files that are no longer part of the managed set.



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'lib/carson/runtime/local.rb', line 379

def template_apply!( push_prep: false )
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	puts_verbose ""
	puts_verbose "[Template Sync Apply]"
	results = template_results
	stale = template_superseded_present
	applied = 0
	results.each do |entry|
		if entry.fetch( :status ) == "error"
			puts_verbose "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}"
			next
		end

		file_path = File.join( repo_root, entry.fetch( :file ) )
		if entry.fetch( :status ) == "ok"
			puts_verbose "template_file: #{entry.fetch( :file )} status=ok reason=in_sync"
			next
		end

		FileUtils.mkdir_p( File.dirname( file_path ) )
		File.write( file_path, entry.fetch( :applied_content ) )
		puts_verbose "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}"
		applied += 1
	end

	removed = 0
	stale.each do |file|
		file_path = resolve_repo_path!( relative_path: file, label: "template.superseded_files entry #{file}" )
		File.delete( file_path )
		puts_verbose "template_file: #{file} status=removed reason=superseded"
		removed += 1
	end

	error_count = results.count { |entry| entry.fetch( :status ) == "error" }
	puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}"
	unless verbose?
		if applied.positive? || removed.positive?
			summary_parts = []
			summary_parts << "#{applied} updated" if applied.positive?
			summary_parts << "#{removed} removed" if removed.positive?
			puts_line "Templates applied (#{summary_parts.join( ", " )})."
		else
			puts_line "Templates in sync."
		end
	end
	return EXIT_ERROR if error_count.positive?

	return EXIT_BLOCK if push_prep && push_prep_commit!
	EXIT_OK
end

#template_check!Object

Read-only template drift check; returns block when managed files are out of sync.



344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/carson/runtime/local.rb', line 344

def template_check!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	puts_verbose ""
	puts_verbose "[Template Sync Check]"
	results = template_results
	stale = template_superseded_present
	drift_count = results.count { |entry| entry.fetch( :status ) == "drift" }
	error_count = results.count { |entry| entry.fetch( :status ) == "error" }
	stale_count = stale.count
	results.each do |entry|
		puts_verbose "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
	end
	stale.each { |file| puts_verbose "template_file: #{file} status=stale reason=superseded" }
	puts_verbose "template_summary: total=#{results.count} drift=#{drift_count} stale=#{stale_count} error=#{error_count}"
	unless verbose?
		if ( drift_count + stale_count ).positive?
			summary_parts = []
			summary_parts << "#{drift_count} of #{results.count} drifted" if drift_count.positive?
			summary_parts << "#{stale_count} stale" if stale_count.positive?
			puts_line "Templates: #{summary_parts.join( ", " )}"
			results.select { |entry| entry.fetch( :status ) == "drift" }.each { |entry| puts_line "  #{entry.fetch( :file )}" }
			stale.each { |file| puts_line "  #{file} — superseded" }
		else
			puts_line "Templates: #{results.count} files in sync"
		end
	end
	return EXIT_ERROR if error_count.positive?

	( drift_count + stale_count ).positive? ? EXIT_BLOCK : EXIT_OK
end