carson 3.22.0 → 3.22.1

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.
@@ -24,15 +24,16 @@ module Carson
24
24
  puts_line "Review Gate"
25
25
  end
26
26
  unless gh_available?
27
- puts_line "ERROR: gh CLI not available in PATH."
27
+ puts_line "gh CLI not found in PATH — install it to use review commands."
28
28
  return EXIT_ERROR
29
29
  end
30
30
 
31
31
  owner, repo = repository_coordinates
32
+ branch = current_branch
32
33
  pr_number_override = carson_pr_number_override
33
34
  pr_summary =
34
35
  if pr_number_override.nil?
35
- current_pull_request_for_branch( branch_name: current_branch )
36
+ current_pull_request_for_branch( branch_name: branch )
36
37
  else
37
38
  details = pull_request_details( owner: owner, repo: repo, pr_number: pr_number_override )
38
39
  {
@@ -43,93 +44,36 @@ module Carson
43
44
  }
44
45
  end
45
46
  if pr_summary.nil?
46
- puts_line "BLOCK: no pull request found for branch #{current_branch}."
47
- report = {
48
- generated_at: Time.now.utc.iso8601,
49
- branch: current_branch,
50
- status: "block",
51
- converged: false,
52
- wait_seconds: config.review_wait_seconds,
53
- poll_seconds: config.review_poll_seconds,
54
- max_polls: config.review_max_polls,
55
- block_reasons: [ "no pull request found for current branch" ],
56
- pr: nil,
57
- unresolved_threads: [],
58
- actionable_top_level: [],
59
- unacknowledged_actionable: []
60
- }
47
+ puts_line "No pull request found for branch #{branch}."
48
+ report = review_gate_report_for_missing_pr( branch_name: branch )
61
49
  write_review_gate_report( report: report )
62
50
  return EXIT_BLOCK
63
51
  end
64
52
 
65
- pre_snapshot = wait_for_review_warmup( owner: owner, repo: repo, pr_number: pr_summary.fetch( :number ) )
66
- converged = false
67
- last_snapshot = pre_snapshot
68
- last_signature = pre_snapshot.nil? ? nil : review_gate_signature( snapshot: pre_snapshot )
69
- poll_attempts = 0
70
-
71
- config.review_max_polls.times do |index|
72
- poll_attempts = index + 1
73
- snapshot = review_gate_snapshot( owner: owner, repo: repo, pr_number: pr_summary.fetch( :number ) )
74
- last_snapshot = snapshot
75
- signature = review_gate_signature( snapshot: snapshot )
76
- puts_verbose "poll_attempt: #{poll_attempts}/#{config.review_max_polls}"
77
- puts_verbose "latest_activity: #{snapshot.fetch( :latest_activity ) || 'unknown'}"
78
- puts_verbose "unresolved_threads: #{snapshot.fetch( :unresolved_threads ).count}"
79
- puts_verbose "unacknowledged_actionable: #{snapshot.fetch( :unacknowledged_actionable ).count}"
80
- if !last_signature.nil? && signature == last_signature
81
- converged = true
82
- puts_verbose "convergence: stable"
83
- break
84
- end
85
- last_signature = signature
86
- wait_for_review_poll if index < config.review_max_polls - 1
87
- end
88
-
89
- block_reasons = []
90
- block_reasons << "review snapshot did not converge within #{config.review_max_polls} polls" unless converged
91
- if last_snapshot.fetch( :unresolved_threads ).any?
92
- block_reasons << "unresolved review threads remain (#{last_snapshot.fetch( :unresolved_threads ).count})"
93
- end
94
- if last_snapshot.fetch( :unacknowledged_actionable ).any?
95
- block_reasons << "actionable top-level comments/reviews without required disposition (#{last_snapshot.fetch( :unacknowledged_actionable ).count})"
96
- end
97
-
98
- report = {
99
- generated_at: Time.now.utc.iso8601,
100
- branch: current_branch,
101
- status: block_reasons.empty? ? "ok" : "block",
102
- converged: converged,
103
- wait_seconds: config.review_wait_seconds,
104
- poll_seconds: config.review_poll_seconds,
105
- max_polls: config.review_max_polls,
106
- poll_attempts: poll_attempts,
107
- block_reasons: block_reasons,
108
- pr: {
109
- number: pr_summary.fetch( :number ),
110
- title: pr_summary.fetch( :title ),
111
- url: pr_summary.fetch( :url ),
112
- state: pr_summary.fetch( :state )
113
- },
114
- unresolved_threads: last_snapshot.fetch( :unresolved_threads ),
115
- actionable_top_level: last_snapshot.fetch( :actionable_top_level ),
116
- unacknowledged_actionable: last_snapshot.fetch( :unacknowledged_actionable )
117
- }
53
+ report = review_gate_report_for_pr(
54
+ owner: owner,
55
+ repo: repo,
56
+ pr_number: pr_summary.fetch( :number ),
57
+ branch_name: branch,
58
+ pr_summary: pr_summary
59
+ )
118
60
  write_review_gate_report( report: report )
119
61
  unless verbose?
62
+ poll_attempts = report.fetch( :poll_attempts, 0 )
120
63
  puts_line "Polling... (converged after #{poll_attempts} attempt#{plural_suffix( count: poll_attempts )})"
121
64
  end
65
+ block_reasons = report.fetch( :block_reasons )
122
66
  if block_reasons.empty?
123
67
  puts_line "OK: review gate passed."
124
68
  return EXIT_OK
125
69
  end
126
- block_reasons.each { |reason| puts_line "BLOCK: #{reason}" }
70
+ block_reasons.each { |reason| puts_line reason }
127
71
  EXIT_BLOCK
128
72
  rescue JSON::ParserError => exception
129
- puts_line "ERROR: invalid gh JSON response (#{exception.message})."
73
+ puts_line "Unexpected response from gh (#{exception.message})."
130
74
  EXIT_ERROR
131
75
  rescue StandardError => exception
132
- puts_line "ERROR: #{exception.message}"
76
+ puts_line exception.message
133
77
  EXIT_ERROR
134
78
  end
135
79
 
@@ -140,7 +84,7 @@ module Carson
140
84
  puts_verbose ""
141
85
  puts_verbose "[Review Sweep]"
142
86
  unless gh_available?
143
- puts_line "ERROR: gh CLI not available in PATH."
87
+ puts_line "gh CLI not found in PATH — install it to use review commands."
144
88
  return EXIT_ERROR
145
89
  end
146
90
 
@@ -176,13 +120,13 @@ module Carson
176
120
  puts_line "OK: no actionable late review activity detected."
177
121
  return EXIT_OK
178
122
  end
179
- puts_line "BLOCK: actionable late review activity detected."
123
+ puts_line "Late review activity needs attention."
180
124
  EXIT_BLOCK
181
125
  rescue JSON::ParserError => exception
182
- puts_line "ERROR: invalid gh JSON response (#{exception.message})."
126
+ puts_line "Unexpected response from gh (#{exception.message})."
183
127
  EXIT_ERROR
184
128
  rescue StandardError => exception
185
- puts_line "ERROR: #{exception.message}"
129
+ puts_line exception.message
186
130
  EXIT_ERROR
187
131
  end
188
132
  end
@@ -45,8 +45,8 @@ module Carson
45
45
  merge_choice = prompt_merge_method
46
46
  choices[ "govern.merge.method" ] = merge_choice unless merge_choice.nil?
47
47
 
48
- canonical_choice = prompt_canonical_template
49
- choices[ "template.canonical" ] = canonical_choice unless canonical_choice.nil?
48
+ canonical_choice = prompt_canonical_lint_policy
49
+ choices[ "lint.canonical" ] = canonical_choice unless canonical_choice.nil?
50
50
 
51
51
  write_setup_config( choices: choices )
52
52
  end
@@ -157,15 +157,15 @@ module Carson
157
157
  prompt_choice( options: options, default: default_index )
158
158
  end
159
159
 
160
- def prompt_canonical_template
160
+ def prompt_canonical_lint_policy
161
161
  puts_line ""
162
- puts_line "Canonical template directory"
163
- current = config.template_canonical
162
+ puts_line "Canonical lint policy directory"
163
+ current = config.lint_canonical
164
164
  if current && !current.empty?
165
165
  puts_line " Currently set to: #{current}"
166
166
  puts_line " Leave blank to keep current value."
167
167
  else
168
- puts_line " A directory of .github/ files to sync across governed repos."
168
+ puts_line " A directory of canonical lint-policy files to sync across governed repos."
169
169
  puts_line " Leave blank to skip for now."
170
170
  end
171
171
  prompt_custom_value( label: "Path" )
@@ -327,6 +327,7 @@ module Carson
327
327
 
328
328
  existing_data = load_existing_config( path: config_path )
329
329
  merged = Config.deep_merge( base: existing_data, overlay: config_data )
330
+ remove_deprecated_template_canonical!( data: merged ) if config_data.dig( "lint", "canonical" )
330
331
 
331
332
  FileUtils.mkdir_p( File.dirname( config_path ) )
332
333
  File.write( config_path, JSON.pretty_generate( merged ) )
@@ -361,6 +362,14 @@ module Carson
361
362
  {}
362
363
  end
363
364
 
365
+ def remove_deprecated_template_canonical!( data: )
366
+ template_hash = data[ "template" ]
367
+ return unless template_hash.is_a?( Hash )
368
+
369
+ template_hash.delete( "canonical" )
370
+ data.delete( "template" ) if template_hash.empty?
371
+ end
372
+
364
373
  def reload_config_after_setup!
365
374
  @config = Config.load( repo_root: repo_root )
366
375
  end
@@ -53,7 +53,7 @@ module Carson
53
53
  repos.each do |repo_path|
54
54
  repo_name = File.basename( repo_path )
55
55
  unless Dir.exist?( repo_path )
56
- puts_line "#{repo_name}: MISSING"
56
+ puts_line "#{repo_name}: not found"
57
57
  next
58
58
  end
59
59
 
@@ -61,7 +61,7 @@ module Carson
61
61
  scoped_runtime = build_scoped_runtime( repo_path: repo_path )
62
62
  data = scoped_runtime.send( :gather_status )
63
63
  branch = data.fetch( :branch )
64
- dirty = branch.fetch( :dirty ) ? " (dirty)" : ""
64
+ dirty = format_dirty_marker( branch: branch )
65
65
  worktrees = data.fetch( :worktrees )
66
66
  gov = data.fetch( :governance )
67
67
  parts = []
@@ -74,14 +74,16 @@ module Carson
74
74
  repo_pending = status_pending_for_repo( all_pending: all_pending, repo_path: repo_path )
75
75
  repo_pending.each { |description| puts_line " pending: #{description}" }
76
76
  rescue StandardError => exception
77
- puts_line "#{repo_name}: FAIL (#{exception.message})"
77
+ puts_line "#{repo_name}: could not read (#{exception.message})"
78
78
  end
79
79
  end
80
80
 
81
81
  EXIT_OK
82
82
  end
83
83
 
84
+ # rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
84
85
  private
86
+ # rubocop:enable Layout/AccessModifierIndentation
85
87
 
86
88
  # Returns an array of human-readable pending descriptions for a repo.
87
89
  def status_pending_for_repo( all_pending:, repo_path: )
@@ -119,10 +121,10 @@ module Carson
119
121
  # Branch name, clean/dirty state, sync status with remote.
120
122
  def gather_branch_info
121
123
  branch = current_branch
122
- dirty = working_tree_dirty?
124
+ dirty_reason = dirty_worktree_reason
123
125
  sync = remote_sync_status( branch: branch )
124
126
 
125
- { name: branch, dirty: dirty, sync: sync }
127
+ { name: branch, dirty: !dirty_reason.nil?, dirty_reason: dirty_reason, sync: sync }
126
128
  end
127
129
 
128
130
  # Returns true when the working tree has uncommitted changes.
@@ -132,6 +134,17 @@ module Carson
132
134
  !stdout.strip.empty?
133
135
  end
134
136
 
137
+ def dirty_worktree_reason
138
+ return nil unless working_tree_dirty?
139
+ return "main_worktree" if main_worktree_context?
140
+
141
+ "working_tree"
142
+ end
143
+
144
+ def main_worktree_context?
145
+ realpath_safe( repo_root ) == realpath_safe( main_worktree_root )
146
+ end
147
+
135
148
  # Compares local branch against its remote tracking ref.
136
149
  # Returns :in_sync, :ahead, :behind, :diverged, or :no_remote.
137
150
  def remote_sync_status( branch: )
@@ -162,11 +175,11 @@ module Carson
162
175
  # Filter output the main worktree (the repository root itself).
163
176
  # Use realpath for comparison — git returns canonical paths that may differ from repo_root.
164
177
  canonical_root = realpath_safe( repo_root )
165
- entries.reject { it.fetch( :path ) == canonical_root }.map do |worktree|
178
+ entries.reject { it.path == canonical_root }.map do |worktree|
166
179
  {
167
- path: worktree.fetch( :path ),
168
- name: File.basename( worktree.fetch( :path ) ),
169
- branch: worktree.fetch( :branch, nil )
180
+ path: worktree.path,
181
+ name: File.basename( worktree.path ),
182
+ branch: worktree.branch
170
183
  }
171
184
  end
172
185
  end
@@ -243,9 +256,12 @@ module Carson
243
256
 
244
257
  # Branch
245
258
  branch = data.fetch( :branch )
246
- dirty_marker = branch.fetch( :dirty ) ? " (dirty)" : ""
259
+ dirty_marker = format_dirty_marker( branch: branch )
247
260
  sync_marker = format_sync( sync: branch.fetch( :sync ) )
248
261
  puts_line "Branch: #{branch.fetch( :name )}#{dirty_marker}#{sync_marker}"
262
+ if branch.fetch( :dirty_reason, nil ) == "main_worktree"
263
+ puts_line "Governance: main working tree has uncommitted changes — create a worktree with `carson worktree create <name>`."
264
+ end
249
265
 
250
266
  # Worktrees
251
267
  worktrees = data.fetch( :worktrees )
@@ -299,8 +315,15 @@ module Carson
299
315
  else ""
300
316
  end
301
317
  end
302
- end
303
318
 
304
- include Status
319
+ def format_dirty_marker( branch: )
320
+ return "" unless branch.fetch( :dirty )
321
+ return " (dirty main worktree)" if branch.fetch( :dirty_reason, nil ) == "main_worktree"
322
+
323
+ " (dirty)"
324
+ end
325
+ end
326
+
327
+ include Status
305
328
  end
306
329
  end
@@ -310,9 +310,9 @@ module Carson
310
310
  # so only genuinely active worktrees block the operation.
311
311
  scoped_runtime = build_scoped_runtime( repo_path: repo_path )
312
312
  scoped_runtime.sweep_stale_worktrees!
313
- worktrees = scoped_runtime.send( :worktree_list )
314
- main_root = scoped_runtime.send( :realpath_safe, repo_path )
315
- active = worktrees.reject { |worktree| worktree.fetch( :path ) == main_root }
313
+ worktrees = scoped_runtime.worktree_list
314
+ main_root = scoped_runtime.realpath_safe( repo_path )
315
+ active = worktrees.reject { |worktree| worktree.path == main_root }
316
316
  if active.any?
317
317
  reasons << "#{active.count} active worktree#{active.count == 1 ? '' : 's'}"
318
318
  end
@@ -346,3 +346,15 @@ require_relative "runtime/govern"
346
346
  require_relative "runtime/setup"
347
347
  require_relative "runtime/status"
348
348
  require_relative "runtime/deliver"
349
+
350
+ # Infrastructure interface for domain objects.
351
+ # Carson::Worktree and future domain objects call these methods
352
+ # on a runtime reference — the way ActiveRecord models use a connection.
353
+ # Defined private for internal use, exposed here for domain object access.
354
+ module Carson
355
+ class Runtime
356
+ public :config, :output, :verbose?, :puts_verbose, :puts_line,
357
+ :git_run, :git_capture!, :main_worktree_root, :realpath_safe,
358
+ :block_if_outsider_fingerprints!, :branch_absorbed_into_main?
359
+ end
360
+ end