carson 2.24.0 → 2.25.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.
data/lib/carson/config.rb CHANGED
@@ -7,16 +7,14 @@ module Carson
7
7
  # Config is built-in only for outsider mode; host repositories do not carry Carson config files.
8
8
  class Config
9
9
  attr_accessor :git_remote
10
- attr_reader :main_branch, :protected_branches, :hooks_base_path, :required_hooks,
11
- :template_managed_files, :template_superseded_files, :template_canonical,
12
- :lint_policy_source,
10
+ attr_reader :main_branch, :protected_branches, :hooks_path, :managed_hooks,
11
+ :template_managed_files, :template_canonical,
13
12
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
14
- :review_sweep_states, :review_disposition_prefix, :review_risk_keywords,
13
+ :review_sweep_states, :review_disposition, :review_risk_keywords,
15
14
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
16
- :ruby_indentation,
17
15
  :audit_advisory_check_names,
18
16
  :workflow_style,
19
- :govern_repos, :govern_merge_authority, :govern_merge_method,
17
+ :govern_repos, :govern_auto_merge, :govern_merge_method,
20
18
  :govern_agent_provider, :govern_dispatch_state_path,
21
19
  :govern_check_wait
22
20
 
@@ -35,17 +33,13 @@ module Carson
35
33
  "protected_branches" => [ "main", "master" ]
36
34
  },
37
35
  "hooks" => {
38
- "base_path" => "~/.carson/hooks",
39
- "required_hooks" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
36
+ "path" => "~/.carson/hooks",
37
+ "managed" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
40
38
  },
41
39
  "template" => {
42
40
  "managed_files" => [ ".github/carson.md", ".github/copilot-instructions.md", ".github/CLAUDE.md", ".github/AGENTS.md", ".github/pull_request_template.md" ],
43
- "superseded_files" => [ ".github/carson-instructions.md", ".github/workflows/carson-lint.yml", ".github/.mega-linter.yml" ],
44
41
  "canonical" => nil
45
42
  },
46
- "lint" => {
47
- "policy_source" => "wanghailei/lint.git"
48
- },
49
43
  "workflow" => {
50
44
  "style" => "branch"
51
45
  },
@@ -54,7 +48,7 @@ module Carson
54
48
  "wait_seconds" => 10,
55
49
  "poll_seconds" => 15,
56
50
  "max_polls" => 20,
57
- "required_disposition_prefix" => "Disposition:",
51
+ "disposition" => "Disposition:",
58
52
  "risk_keywords" => [ "bug", "security", "incorrect", "block", "fail", "regression" ],
59
53
  "sweep" => {
60
54
  "window_days" => 3,
@@ -70,10 +64,8 @@ module Carson
70
64
  },
71
65
  "govern" => {
72
66
  "repos" => [],
73
- "merge" => {
74
- "authority" => true,
75
- "method" => "squash"
76
- },
67
+ "auto_merge" => true,
68
+ "merge_method" => "squash",
77
69
  "agent" => {
78
70
  "provider" => "auto",
79
71
  "codex" => {},
@@ -81,11 +73,8 @@ module Carson
81
73
  },
82
74
  "dispatch_state_path" => "~/.carson/govern/dispatch_state.json",
83
75
  "check_wait" => 30
84
- },
85
- "style" => {
86
- "ruby_indentation" => "tabs"
87
- }
88
76
  }
77
+ }
89
78
  end
90
79
 
91
80
  def self.load_global_config_data( repo_root: )
@@ -138,8 +127,8 @@ module Carson
138
127
  def self.apply_env_overrides( data: )
139
128
  copy = deep_dup_value( value: data )
140
129
  hooks = fetch_hash_section( data: copy, key: "hooks" )
141
- hooks_path = ENV.fetch( "CARSON_HOOKS_BASE_PATH", "" ).to_s.strip
142
- hooks[ "base_path" ] = hooks_path unless hooks_path.empty?
130
+ hooks_path = ENV.fetch( "CARSON_HOOKS_PATH", "" ).to_s.strip
131
+ hooks[ "path" ] = hooks_path unless hooks_path.empty?
143
132
  workflow = fetch_hash_section( data: copy, key: "workflow" )
144
133
  workflow_style = ENV.fetch( "CARSON_WORKFLOW_STYLE", "" ).to_s.strip
145
134
  workflow[ "style" ] = workflow_style unless workflow_style.empty?
@@ -147,8 +136,8 @@ module Carson
147
136
  review[ "wait_seconds" ] = env_integer( key: "CARSON_REVIEW_WAIT_SECONDS", fallback: review.fetch( "wait_seconds" ) )
148
137
  review[ "poll_seconds" ] = env_integer( key: "CARSON_REVIEW_POLL_SECONDS", fallback: review.fetch( "poll_seconds" ) )
149
138
  review[ "max_polls" ] = env_integer( key: "CARSON_REVIEW_MAX_POLLS", fallback: review.fetch( "max_polls" ) )
150
- disposition_prefix = ENV.fetch( "CARSON_REVIEW_DISPOSITION_PREFIX", "" ).to_s.strip
151
- review[ "required_disposition_prefix" ] = disposition_prefix unless disposition_prefix.empty?
139
+ disposition = ENV.fetch( "CARSON_REVIEW_DISPOSITION", "" ).to_s.strip
140
+ review[ "disposition" ] = disposition unless disposition.empty?
152
141
  sweep = fetch_hash_section( data: review, key: "sweep" )
153
142
  sweep[ "window_days" ] = env_integer( key: "CARSON_REVIEW_SWEEP_WINDOW_DAYS", fallback: sweep.fetch( "window_days" ) )
154
143
  states = env_string_array( key: "CARSON_REVIEW_SWEEP_STATES" )
@@ -158,20 +147,13 @@ module Carson
158
147
  audit = fetch_hash_section( data: copy, key: "audit" )
159
148
  advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
160
149
  audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
161
- lint = fetch_hash_section( data: copy, key: "lint" )
162
- lint_policy_source_env = ENV.fetch( "CARSON_LINT_POLICY_SOURCE", "" ).to_s.strip
163
- lint[ "policy_source" ] = lint_policy_source_env unless lint_policy_source_env.empty?
164
- style = fetch_hash_section( data: copy, key: "style" )
165
- ruby_indentation = ENV.fetch( "CARSON_RUBY_INDENTATION", "" ).to_s.strip
166
- style[ "ruby_indentation" ] = ruby_indentation unless ruby_indentation.empty?
167
150
  govern = fetch_hash_section( data: copy, key: "govern" )
168
151
  govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
169
152
  govern[ "repos" ] = govern_repos unless govern_repos.empty?
170
- merge = fetch_hash_section( data: govern, key: "merge" )
171
- govern_authority = ENV.fetch( "CARSON_GOVERN_MERGE_AUTHORITY", "" ).to_s.strip
172
- merge[ "authority" ] = ( govern_authority == "true" ) unless govern_authority.empty?
153
+ govern_auto_merge = ENV.fetch( "CARSON_GOVERN_AUTO_MERGE", "" ).to_s.strip
154
+ govern[ "auto_merge" ] = ( govern_auto_merge == "true" ) unless govern_auto_merge.empty?
173
155
  govern_method = ENV.fetch( "CARSON_GOVERN_MERGE_METHOD", "" ).to_s.strip
174
- merge[ "method" ] = govern_method unless govern_method.empty?
156
+ govern[ "merge_method" ] = govern_method unless govern_method.empty?
175
157
  agent = fetch_hash_section( data: govern, key: "agent" )
176
158
  govern_provider = ENV.fetch( "CARSON_GOVERN_AGENT_PROVIDER", "" ).to_s.strip
177
159
  agent[ "provider" ] = govern_provider unless govern_provider.empty?
@@ -203,15 +185,12 @@ module Carson
203
185
  @main_branch = fetch_string( hash: fetch_hash( hash: data, key: "git" ), key: "main_branch" )
204
186
  @protected_branches = fetch_string_array( hash: fetch_hash( hash: data, key: "git" ), key: "protected_branches" )
205
187
 
206
- @hooks_base_path = fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "base_path" )
207
- @required_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "required_hooks" )
188
+ @hooks_path = fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "path" )
189
+ @managed_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "managed" )
208
190
 
209
191
  @template_managed_files = fetch_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "managed_files" )
210
- @template_superseded_files = fetch_optional_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "superseded_files" )
211
192
  @template_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "template" ), key: "canonical" )
212
193
  resolve_canonical_files!
213
- lint_hash = fetch_hash( hash: data, key: "lint" )
214
- @lint_policy_source = lint_hash.fetch( "policy_source", "" ).to_s.strip
215
194
 
216
195
  workflow_hash = fetch_hash( hash: data, key: "workflow" )
217
196
  @workflow_style = fetch_string( hash: workflow_hash, key: "style" ).downcase
@@ -220,7 +199,7 @@ module Carson
220
199
  @review_wait_seconds = fetch_non_negative_integer( hash: review_hash, key: "wait_seconds" )
221
200
  @review_poll_seconds = fetch_non_negative_integer( hash: review_hash, key: "poll_seconds" )
222
201
  @review_max_polls = fetch_positive_integer( hash: review_hash, key: "max_polls" )
223
- @review_disposition_prefix = fetch_string( hash: review_hash, key: "required_disposition_prefix" )
202
+ @review_disposition = fetch_string( hash: review_hash, key: "disposition" )
224
203
  @review_risk_keywords = fetch_string_array( hash: review_hash, key: "risk_keywords" )
225
204
  sweep_hash = fetch_hash( hash: review_hash, key: "sweep" )
226
205
  @review_sweep_window_days = fetch_positive_integer( hash: sweep_hash, key: "window_days" )
@@ -231,14 +210,11 @@ module Carson
231
210
  @review_bot_usernames = fetch_optional_string_array( hash: review_hash, key: "bot_usernames" )
232
211
  audit_hash = fetch_hash( hash: data, key: "audit" )
233
212
  @audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
234
- style_hash = fetch_hash( hash: data, key: "style" )
235
- @ruby_indentation = fetch_string( hash: style_hash, key: "ruby_indentation" ).downcase
236
213
 
237
214
  govern_hash = fetch_hash( hash: data, key: "govern" )
238
215
  @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |p| safe_expand_path( p ) }
239
- govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
240
- @govern_merge_authority = fetch_optional_boolean( hash: govern_merge_hash, key: "authority", default: true, key_path: "govern.merge.authority" )
241
- @govern_merge_method = fetch_string( hash: govern_merge_hash, key: "method" ).downcase
216
+ @govern_auto_merge = fetch_optional_boolean( hash: govern_hash, key: "auto_merge", default: true, key_path: "govern.auto_merge" )
217
+ @govern_merge_method = fetch_string( hash: govern_hash, key: "merge_method" ).downcase
242
218
  govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
243
219
  @govern_agent_provider = fetch_string( hash: govern_agent_hash, key: "provider" ).downcase
244
220
  dispatch_path = govern_hash.fetch( "dispatch_state_path" ).to_s
@@ -254,16 +230,15 @@ module Carson
254
230
  raise ConfigError, "git.remote cannot be empty" if git_remote.empty?
255
231
  raise ConfigError, "git.main_branch cannot be empty" if main_branch.empty?
256
232
  raise ConfigError, "git.protected_branches must include #{main_branch}" unless protected_branches.include?( main_branch )
257
- raise ConfigError, "hooks.base_path cannot be empty" if hooks_base_path.empty?
258
- raise ConfigError, "hooks.required_hooks cannot be empty" if required_hooks.empty?
259
- raise ConfigError, "review.required_disposition_prefix cannot be empty" if review_disposition_prefix.empty?
233
+ raise ConfigError, "hooks.path cannot be empty" if hooks_path.empty?
234
+ raise ConfigError, "hooks.managed cannot be empty" if managed_hooks.empty?
235
+ raise ConfigError, "review.disposition cannot be empty" if review_disposition.empty?
260
236
  raise ConfigError, "review.risk_keywords cannot be empty" if review_risk_keywords.empty?
261
237
  raise ConfigError, "review.sweep.states must contain one or both of open, closed" if ( review_sweep_states - [ "open", "closed" ] ).any? || review_sweep_states.empty?
262
238
  raise ConfigError, "review.sweep.states cannot contain duplicates" unless review_sweep_states.uniq.length == review_sweep_states.length
263
239
  raise ConfigError, "review.tracking_issue.title cannot be empty" if review_tracking_issue_title.empty?
264
240
  raise ConfigError, "review.tracking_issue.label cannot be empty" if review_tracking_issue_label.empty?
265
241
  raise ConfigError, "workflow.style must be one of trunk, branch" unless [ "trunk", "branch" ].include?( workflow_style )
266
- raise ConfigError, "style.ruby_indentation must be one of tabs, spaces, either" unless [ "tabs", "spaces", "either" ].include?( ruby_indentation )
267
242
  raise ConfigError, "govern.merge.method must be one of merge, squash, rebase" unless [ "merge", "squash", "rebase" ].include?( govern_merge_method )
268
243
  raise ConfigError, "govern.agent.provider must be one of auto, codex, claude" unless [ "auto", "codex", "claude" ].include?( govern_agent_provider )
269
244
  end
@@ -24,7 +24,7 @@ module Carson
24
24
  hooks_ok = hooks_health_report
25
25
  unless hooks_ok
26
26
  audit_state = "block"
27
- audit_concise_problems << "Hooks: mismatch — run carson prepare."
27
+ audit_concise_problems << "Hooks: mismatch — run carson refresh."
28
28
  end
29
29
  puts_verbose ""
30
30
  puts_verbose "[Main Sync Status]"
@@ -115,64 +115,6 @@ module Carson
115
115
  audit_state == "block" ? EXIT_BLOCK : EXIT_OK
116
116
  end
117
117
 
118
- # Thin focused command: show required-check status for the current branch's open PR.
119
- # Always exits 0 for pending or passing so callers never see a false "Error: Exit code 8".
120
- def check!
121
- unless head_exists?
122
- puts_line "Checks: no commits yet."
123
- return EXIT_OK
124
- end
125
- unless gh_available?
126
- puts_line "Checks: gh CLI not available."
127
- return EXIT_ERROR
128
- end
129
-
130
- pr_stdout, pr_stderr, pr_success, = gh_run(
131
- "pr", "view", current_branch,
132
- "--json", "number,title,url"
133
- )
134
- unless pr_success
135
- error_text = gh_error_text( stdout_text: pr_stdout, stderr_text: pr_stderr, fallback: "no open PR for branch #{current_branch}" )
136
- puts_line "Checks: #{error_text}."
137
- return EXIT_ERROR
138
- end
139
- pr_data = JSON.parse( pr_stdout )
140
- pr_number = pr_data[ "number" ].to_s
141
-
142
- checks_stdout, checks_stderr, checks_success, checks_exit = gh_run(
143
- "pr", "checks", pr_number, "--required", "--json", "name,state,bucket,workflow,link"
144
- )
145
- if checks_stdout.to_s.strip.empty?
146
- error_text = gh_error_text( stdout_text: checks_stdout, stderr_text: checks_stderr, fallback: "required checks unavailable" )
147
- puts_line "Checks: #{error_text}."
148
- return EXIT_ERROR
149
- end
150
-
151
- checks_data = JSON.parse( checks_stdout )
152
- pending = checks_data.select { |e| e[ "bucket" ].to_s == "pending" }
153
- failing = checks_data.select { |e| check_entry_failing?( entry: e ) }
154
- total = checks_data.count
155
- # gh exits 8 when required checks are still pending (not a failure).
156
- is_pending = !checks_success && checks_exit == 8
157
-
158
- if failing.any?
159
- puts_line "Checks: FAIL (#{failing.count} of #{total} failing)."
160
- normalise_check_entries( entries: failing ).each { |e| puts_line " #{e.fetch( :workflow )} / #{e.fetch( :name )} #{e.fetch( :link )}".strip }
161
- return EXIT_BLOCK
162
- end
163
-
164
- if is_pending || pending.any?
165
- puts_line "Checks: pending (#{total - pending.count} of #{total} complete)."
166
- return EXIT_OK
167
- end
168
-
169
- puts_line "Checks: all passing (#{total} required)."
170
- EXIT_OK
171
- rescue JSON::ParserError => e
172
- puts_line "Checks: invalid gh response (#{e.message})."
173
- EXIT_ERROR
174
- end
175
-
176
118
  private
177
119
  def pr_and_check_report
178
120
  report = {
@@ -81,18 +81,6 @@ module Carson
81
81
  EXIT_OK
82
82
  end
83
83
 
84
- # Standalone housekeep: sync + prune.
85
- def housekeep!
86
- puts_verbose ""
87
- puts_verbose "[Housekeep]"
88
- sync_status = sync!
89
- if sync_status != EXIT_OK
90
- puts_line "housekeep: sync returned #{sync_status}; skipping prune."
91
- return sync_status
92
- end
93
- prune!
94
- end
95
-
96
84
  private
97
85
 
98
86
  # Resolves the list of repo paths to govern from config.
@@ -209,10 +197,6 @@ module Carson
209
197
  return [ TRIAGE_REVIEW_BLOCKED, "changes requested by reviewer" ]
210
198
  end
211
199
 
212
- # Run audit and review gate checks for deeper analysis
213
- audit_status, audit_detail = check_audit_status( pr: pr, repo_path: repo_path )
214
- return [ TRIAGE_NEEDS_ATTENTION, audit_detail ] unless audit_status == :pass
215
-
216
200
  review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
217
201
  return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
218
202
 
@@ -245,20 +229,6 @@ module Carson
245
229
  [ "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED" ].include?( state.upcase )
246
230
  end
247
231
 
248
- # Checks if the PR's branch is available locally and defers audit. Returns [:pass/:fail, detail].
249
- def check_audit_status( pr:, repo_path: )
250
- branch = pr[ "headRefName" ].to_s
251
- stdout_text, stderr_text, status = Open3.capture3(
252
- "git", "rev-parse", "--verify", "refs/heads/#{branch}",
253
- chdir: repo_path
254
- )
255
- unless status.success?
256
- return [ :pass, "branch not local; skipping audit" ]
257
- end
258
-
259
- [ :pass, "audit deferred to merge gate" ]
260
- end
261
-
262
232
  # Checks review gate status. Returns [:pass/:fail, detail].
263
233
  def check_review_gate_status( pr:, repo_path: )
264
234
  review_decision = pr[ "reviewDecision" ].to_s.upcase
@@ -308,7 +278,7 @@ module Carson
308
278
 
309
279
  # Merges a PR that has passed all gates.
310
280
  def merge_if_ready!( pr:, repo_path: )
311
- unless config.govern_merge_authority
281
+ unless config.govern_auto_merge
312
282
  puts_line " merge authority disabled; skipping merge"
313
283
  return
314
284
  end
@@ -373,14 +343,15 @@ module Carson
373
343
  puts_line " agent result: #{result.status} — #{result.summary.to_s[0, 120]}"
374
344
  end
375
345
 
376
- # Runs housekeep in the given repo after a successful merge.
346
+ # Runs sync + prune in the given repo after a successful merge.
377
347
  def housekeep_repo!( repo_path: )
378
- if repo_path == self.repo_root
379
- housekeep!
348
+ rt = if repo_path == self.repo_root
349
+ self
380
350
  else
381
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err )
382
- rt.housekeep!
351
+ Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err )
383
352
  end
353
+ sync_status = rt.sync!
354
+ rt.prune! if sync_status == EXIT_OK
384
355
  end
385
356
 
386
357
  # Selects which agent provider to use based on config and availability.
@@ -0,0 +1,142 @@
1
+ module Carson
2
+ class Runtime
3
+ module Local
4
+ private
5
+
6
+ # Installs required hook files and enforces repository hook path.
7
+ def prepare!
8
+ fingerprint_status = block_if_outsider_fingerprints!
9
+ return fingerprint_status unless fingerprint_status.nil?
10
+
11
+ FileUtils.mkdir_p( hooks_dir )
12
+ missing_templates = config.managed_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
13
+ unless missing_templates.empty?
14
+ puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
15
+ return EXIT_BLOCK
16
+ end
17
+
18
+ symlinked = symlink_hook_files
19
+ unless symlinked.empty?
20
+ puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
21
+ return EXIT_BLOCK
22
+ end
23
+
24
+ config.managed_hooks.each do |hook_name|
25
+ source_path = hook_template_path( hook_name: hook_name )
26
+ target_path = File.join( hooks_dir, hook_name )
27
+ FileUtils.cp( source_path, target_path )
28
+ FileUtils.chmod( 0o755, target_path )
29
+ puts_verbose "hook_written: #{relative_path( target_path )}"
30
+ end
31
+ git_system!( "config", "core.hooksPath", hooks_dir )
32
+ File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
33
+ puts_verbose "configured_hooks_path: #{hooks_dir}"
34
+ puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
35
+ EXIT_OK
36
+ end
37
+
38
+ # Canonical hook template location inside Carson repository.
39
+ def hook_template_path( hook_name: )
40
+ File.join( tool_root, "hooks", hook_name )
41
+ end
42
+
43
+ # Reports full hook health and can enforce stricter action messaging in `check`.
44
+ def hooks_health_report( strict: false )
45
+ configured = configured_hooks_path
46
+ expected = hooks_dir
47
+ hooks_path_ok = print_hooks_path_status( configured: configured, expected: expected )
48
+ print_required_hook_status
49
+ hooks_integrity = hook_integrity_state
50
+ hooks_ok = hooks_integrity_ok?( hooks_integrity: hooks_integrity )
51
+ print_hook_action(
52
+ strict: strict,
53
+ hooks_ok: hooks_path_ok && hooks_ok,
54
+ hooks_path_ok: hooks_path_ok,
55
+ configured: configured,
56
+ expected: expected
57
+ )
58
+ hooks_path_ok && hooks_ok
59
+ end
60
+
61
+ def print_hooks_path_status( configured:, expected: )
62
+ configured_abs = configured.nil? ? nil : File.expand_path( configured )
63
+ hooks_path_ok = configured_abs == expected
64
+ puts_verbose "hooks_path: #{configured || '(unset)'}"
65
+ puts_verbose "hooks_path_expected: #{expected}"
66
+ puts_verbose( hooks_path_ok ? "hooks_path_status: ok" : "hooks_path_status: attention" )
67
+ hooks_path_ok
68
+ end
69
+
70
+ def print_required_hook_status
71
+ required_hook_paths.each do |path|
72
+ exists = File.file?( path )
73
+ symlink = File.symlink?( path )
74
+ executable = exists && !symlink && File.executable?( path )
75
+ puts_verbose "hook_file: #{relative_path( path )} exists=#{exists} symlink=#{symlink} executable=#{executable}"
76
+ end
77
+ end
78
+
79
+ def hook_integrity_state
80
+ {
81
+ missing: missing_hook_files,
82
+ non_executable: non_executable_hook_files,
83
+ symlinked: symlink_hook_files
84
+ }
85
+ end
86
+
87
+ def hooks_integrity_ok?( hooks_integrity: )
88
+ missing_ok = hooks_integrity.fetch( :missing ).empty?
89
+ non_executable_ok = hooks_integrity.fetch( :non_executable ).empty?
90
+ symlinked_ok = hooks_integrity.fetch( :symlinked ).empty?
91
+ missing_ok && non_executable_ok && symlinked_ok
92
+ end
93
+
94
+ def print_hook_action( strict:, hooks_ok:, hooks_path_ok:, configured:, expected: )
95
+ return if hooks_ok
96
+
97
+ if strict && !hooks_path_ok
98
+ configured_text = configured.to_s.strip
99
+ if configured_text.empty?
100
+ puts_verbose "ACTION: hooks path is unset (expected=#{expected})."
101
+ else
102
+ puts_verbose "ACTION: hooks path mismatch (configured=#{configured_text}, expected=#{expected})."
103
+ end
104
+ end
105
+ message = strict ? "ACTION: run carson refresh to align hooks with Carson #{Carson::VERSION}." : "ACTION: run carson refresh to enforce local main protections."
106
+ puts_verbose message
107
+ end
108
+
109
+ # Reads configured core.hooksPath and normalises empty values to nil.
110
+ def configured_hooks_path
111
+ stdout_text, = git_capture_soft( "config", "--get", "core.hooksPath" )
112
+ value = stdout_text.to_s.strip
113
+ value.empty? ? nil : value
114
+ end
115
+
116
+ # Fully-qualified required hook file locations in the target repository.
117
+ def required_hook_paths
118
+ config.managed_hooks.map { |name| File.join( hooks_dir, name ) }
119
+ end
120
+
121
+ # Missing required hook files.
122
+ def missing_hook_files
123
+ required_hook_paths.reject { |path| File.file?( path ) }.map { |path| relative_path( path ) }
124
+ end
125
+
126
+ # Required hook files that exist but are not executable.
127
+ def non_executable_hook_files
128
+ required_hook_paths.select { |path| File.file?( path ) && !File.executable?( path ) }.map { |path| relative_path( path ) }
129
+ end
130
+
131
+ # Symlink hooks are disallowed to prevent bypassing managed hook content.
132
+ def symlink_hook_files
133
+ required_hook_paths.select { |path| File.symlink?( path ) }.map { |path| relative_path( path ) }
134
+ end
135
+
136
+ # Local directory where managed hooks are installed.
137
+ def hooks_dir
138
+ File.expand_path( File.join( config.hooks_path, Carson::VERSION ) )
139
+ end
140
+ end
141
+ end
142
+ end