carson 3.22.0 → 3.23.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +19 -20
  3. data/MANUAL.md +76 -65
  4. data/README.md +42 -50
  5. data/RELEASE.md +24 -1
  6. data/SKILL.md +1 -1
  7. data/VERSION +1 -1
  8. data/carson.gemspec +3 -4
  9. data/hooks/command-guard +1 -1
  10. data/hooks/pre-push +17 -20
  11. data/lib/carson/adapters/agent.rb +2 -2
  12. data/lib/carson/branch.rb +38 -0
  13. data/lib/carson/cli.rb +45 -30
  14. data/lib/carson/config.rb +80 -29
  15. data/lib/carson/delivery.rb +64 -0
  16. data/lib/carson/ledger.rb +305 -0
  17. data/lib/carson/repository.rb +47 -0
  18. data/lib/carson/revision.rb +30 -0
  19. data/lib/carson/runtime/audit.rb +43 -17
  20. data/lib/carson/runtime/deliver.rb +163 -149
  21. data/lib/carson/runtime/govern.rb +233 -357
  22. data/lib/carson/runtime/housekeep.rb +233 -27
  23. data/lib/carson/runtime/local/onboard.rb +29 -29
  24. data/lib/carson/runtime/local/prune.rb +120 -35
  25. data/lib/carson/runtime/local/sync.rb +29 -7
  26. data/lib/carson/runtime/local/template.rb +30 -12
  27. data/lib/carson/runtime/local/worktree.rb +37 -442
  28. data/lib/carson/runtime/review/gate_support.rb +144 -12
  29. data/lib/carson/runtime/review/sweep_support.rb +2 -2
  30. data/lib/carson/runtime/review/utility.rb +1 -1
  31. data/lib/carson/runtime/review.rb +21 -77
  32. data/lib/carson/runtime/setup.rb +25 -33
  33. data/lib/carson/runtime/status.rb +96 -212
  34. data/lib/carson/runtime.rb +39 -4
  35. data/lib/carson/worktree.rb +497 -0
  36. data/lib/carson.rb +6 -0
  37. metadata +37 -17
  38. data/.github/copilot-instructions.md +0 -1
  39. data/.github/pull_request_template.md +0 -12
  40. data/templates/.github/AGENTS.md +0 -1
  41. data/templates/.github/CLAUDE.md +0 -1
  42. data/templates/.github/carson.md +0 -47
  43. data/templates/.github/copilot-instructions.md +0 -1
  44. data/templates/.github/pull_request_template.md +0 -12
@@ -1,10 +1,8 @@
1
- # Agent briefing one command to know the full state of the estate.
2
- # Gathers branch, worktrees, open PRs, stale branches,
3
- # governance health, and version. Supports human-readable and JSON output.
1
+ # Agent-readable repository status centred on branch deliveries.
4
2
  module Carson
5
3
  class Runtime
6
4
  module Status
7
- # Entry point for `carson status`. Collects estate state and reports.
5
+ # Entry point for `carson status`.
8
6
  def status!( json_output: false )
9
7
  data = gather_status
10
8
 
@@ -19,63 +17,33 @@ module Carson
19
17
 
20
18
  # Portfolio-wide status overview across all governed repositories.
21
19
  def status_all!( json_output: false )
22
- repos = config.govern_repos
23
- if repos.empty?
20
+ repositories = config.govern_repos
21
+ if repositories.empty?
24
22
  puts_line "No governed repositories configured."
25
23
  puts_line " Run carson onboard in each repo to register."
26
24
  return EXIT_ERROR
27
25
  end
28
26
 
29
- if json_output
30
- results = []
31
- repos.each do |repo_path|
32
- repo_name = File.basename( repo_path )
33
- unless Dir.exist?( repo_path )
34
- results << { name: repo_name, status: "error", error: "path not found" }
35
- next
36
- end
27
+ results = repositories.map do |repo_path|
28
+ repo_name = File.basename( repo_path )
29
+ unless Dir.exist?( repo_path )
30
+ { name: repo_name, status: "error", error: "not found" }
31
+ else
37
32
  begin
38
33
  scoped_runtime = build_scoped_runtime( repo_path: repo_path )
39
- data = scoped_runtime.send( :gather_status )
40
- results << { name: repo_name, status: "ok" }.merge( data )
34
+ { name: repo_name, status: "ok" }.merge( scoped_runtime.send( :gather_status ) )
41
35
  rescue StandardError => exception
42
- results << { name: repo_name, status: "error", error: exception.message }
36
+ { name: repo_name, status: "error", error: exception.message }
43
37
  end
44
38
  end
45
- output.puts JSON.pretty_generate( { command: "status", repos: results } )
46
- return EXIT_OK
47
39
  end
48
40
 
49
- puts_line "Carson #{Carson::VERSION} — Portfolio (#{repos.length} repo#{plural_suffix( count: repos.length )})"
50
- puts_line ""
51
-
52
- all_pending = load_batch_pending
53
- repos.each do |repo_path|
54
- repo_name = File.basename( repo_path )
55
- unless Dir.exist?( repo_path )
56
- puts_line "#{repo_name}: MISSING"
57
- next
58
- end
59
-
60
- begin
61
- scoped_runtime = build_scoped_runtime( repo_path: repo_path )
62
- data = scoped_runtime.send( :gather_status )
63
- branch = data.fetch( :branch )
64
- dirty = branch.fetch( :dirty ) ? " (dirty)" : ""
65
- worktrees = data.fetch( :worktrees )
66
- gov = data.fetch( :governance )
67
- parts = []
68
- parts << branch.fetch( :name ) + dirty
69
- parts << "#{worktrees.count} worktree#{plural_suffix( count: worktrees.count )}" if worktrees.any?
70
- parts << "templates #{gov.fetch( :templates )}" unless gov.fetch( :templates ) == :in_sync
71
- puts_line "#{repo_name}: #{parts.join( ' ' )}"
72
-
73
- # Show pending operations for this repo.
74
- repo_pending = status_pending_for_repo( all_pending: all_pending, repo_path: repo_path )
75
- repo_pending.each { |description| puts_line " pending: #{description}" }
76
- rescue StandardError => exception
77
- puts_line "#{repo_name}: FAIL (#{exception.message})"
78
- end
41
+ if json_output
42
+ output.puts JSON.pretty_generate( { command: "status", repos: results, repositories: results } )
43
+ else
44
+ puts_line "Carson #{Carson::VERSION} — Portfolio (#{repositories.length} repo#{plural_suffix( count: repositories.length )})"
45
+ puts_line ""
46
+ results.each { |result| print_portfolio_status( result: result ) }
79
47
  end
80
48
 
81
49
  EXIT_OK
@@ -83,141 +51,77 @@ module Carson
83
51
 
84
52
  private
85
53
 
86
- # Returns an array of human-readable pending descriptions for a repo.
87
- def status_pending_for_repo( all_pending:, repo_path: )
88
- descriptions = []
89
- all_pending.each do |command, repos|
90
- next unless repos.is_a?( Hash ) && repos.key?( repo_path )
91
-
92
- info = repos[ repo_path ]
93
- attempts = info.fetch( "attempts", 0 )
94
- skipped_at = info.fetch( "skipped_at", nil )
95
- time_part = skipped_at ? ", since #{skipped_at[ 11..15 ]}" : ""
96
- descriptions << "#{command} (#{attempts} attempt#{attempts == 1 ? '' : 's'}#{time_part})"
97
- end
98
- descriptions
99
- end
100
-
101
- # Collects all status facets into a structured hash.
102
54
  def gather_status
103
- data = {
55
+ repository = repository_record
56
+ branch = branch_record
57
+ deliveries = ledger.active_deliveries( repo_path: repo_root )
58
+
59
+ {
104
60
  version: Carson::VERSION,
105
- branch: gather_branch_info,
106
- worktrees: gather_worktree_info,
107
- governance: gather_governance_info
61
+ repository: {
62
+ name: repository.name,
63
+ path: repository.path,
64
+ authority: repository.authority
65
+ },
66
+ branch: {
67
+ name: branch.name,
68
+ head: branch.head,
69
+ worktree: branch.worktree,
70
+ dirty: working_tree_dirty?,
71
+ dirty_reason: dirty_worktree_reason,
72
+ sync: remote_sync_status( branch: branch.name )
73
+ },
74
+ branches: deliveries.map { |delivery| status_branch_entry( delivery: delivery ) },
75
+ stale_branches: gather_stale_branch_info
108
76
  }
109
-
110
- # PR and stale branch data require gh — gather with graceful fallback.
111
- if gh_available?
112
- data[ :pull_requests ] = gather_pr_info
113
- data[ :stale_branches ] = gather_stale_branch_info
114
- end
115
-
116
- data
117
77
  end
118
78
 
119
- # Branch name, clean/dirty state, sync status with remote.
120
- def gather_branch_info
121
- branch = current_branch
122
- dirty = working_tree_dirty?
123
- sync = remote_sync_status( branch: branch )
124
-
125
- { name: branch, dirty: dirty, sync: sync }
79
+ def status_branch_entry( delivery: )
80
+ {
81
+ branch: delivery.branch,
82
+ worktree_path: delivery.worktree_path,
83
+ head: delivery.head,
84
+ pr_number: delivery.pull_request_number,
85
+ delivery_state: delivery.status,
86
+ revision_count: delivery.revision_count,
87
+ summary: delivery.summary,
88
+ updated_at: delivery.updated_at
89
+ }
126
90
  end
127
91
 
128
- # Returns true when the working tree has uncommitted changes.
129
92
  def working_tree_dirty?
130
93
  stdout, _, success, = git_run( "status", "--porcelain" )
131
94
  return true unless success
132
95
  !stdout.strip.empty?
133
96
  end
134
97
 
135
- # Compares local branch against its remote tracking ref.
136
- # Returns :in_sync, :ahead, :behind, :diverged, or :no_remote.
98
+ def dirty_worktree_reason
99
+ return nil unless working_tree_dirty?
100
+ return "main_worktree" if main_worktree_context?
101
+
102
+ "working_tree"
103
+ end
104
+
105
+ def main_worktree_context?
106
+ realpath_safe( repo_root ) == realpath_safe( main_worktree_root )
107
+ end
108
+
137
109
  def remote_sync_status( branch: )
138
110
  remote = config.git_remote
139
111
  remote_ref = "#{remote}/#{branch}"
140
-
141
- # Check if the remote ref exists.
142
112
  _, _, exists, = git_run( "rev-parse", "--verify", remote_ref )
143
113
  return :no_remote unless exists
144
114
 
145
115
  ahead_behind, _, success, = git_run( "rev-list", "--left-right", "--count", "#{branch}...#{remote_ref}" )
146
116
  return :unknown unless success
147
117
 
148
- parts = ahead_behind.strip.split( /\s+/ )
149
- ahead = parts[ 0 ].to_i
150
- behind = parts[ 1 ].to_i
151
-
118
+ ahead, behind = ahead_behind.strip.split.map( &:to_i )
152
119
  return :in_sync if ahead.zero? && behind.zero?
153
120
  return :ahead if behind.zero?
154
121
  return :behind if ahead.zero?
155
122
  :diverged
156
123
  end
157
124
 
158
- # Lists all worktrees with branch name.
159
- def gather_worktree_info
160
- entries = worktree_list
161
-
162
- # Filter output the main worktree (the repository root itself).
163
- # Use realpath for comparison — git returns canonical paths that may differ from repo_root.
164
- canonical_root = realpath_safe( repo_root )
165
- entries.reject { it.fetch( :path ) == canonical_root }.map do |worktree|
166
- {
167
- path: worktree.fetch( :path ),
168
- name: File.basename( worktree.fetch( :path ) ),
169
- branch: worktree.fetch( :branch, nil )
170
- }
171
- end
172
- end
173
-
174
- # Queries open PRs via gh.
175
- def gather_pr_info
176
- stdout, _, success, = gh_run(
177
- "pr", "list", "--state", "open",
178
- "--json", "number,title,headRefName,statusCheckRollup,reviewDecision"
179
- )
180
- return [] unless success
181
-
182
- prs = JSON.parse( stdout ) rescue []
183
- prs.map do |pr|
184
- ci = summarise_checks( rollup: pr[ "statusCheckRollup" ] )
185
- review = pr[ "reviewDecision" ].to_s
186
- review_label = review_decision_label( decision: review )
187
-
188
- {
189
- number: pr[ "number" ],
190
- title: pr[ "title" ],
191
- branch: pr[ "headRefName" ],
192
- ci: ci,
193
- review: review_label
194
- }
195
- end
196
- end
197
-
198
- # Summarises check rollup into a single status word.
199
- def summarise_checks( rollup: )
200
- entries = Array( rollup )
201
- return :none if entries.empty?
202
-
203
- states = entries.map { it[ "conclusion" ].to_s.upcase }
204
- return :fail if states.any? { it == "FAILURE" || it == "CANCELLED" || it == "TIMED_OUT" }
205
- return :pending if states.any? { it == "" || it == "PENDING" || it == "QUEUED" || it == "IN_PROGRESS" }
206
-
207
- :pass
208
- end
209
-
210
- # Translates GitHub review decision to a concise label.
211
- def review_decision_label( decision: )
212
- case decision.upcase
213
- when "APPROVED" then :approved
214
- when "CHANGES_REQUESTED" then :changes_requested
215
- when "REVIEW_REQUIRED" then :review_required
216
- else :none
217
- end
218
- end
219
-
220
- # Counts local branches that are stale (tracking a deleted upstream).
221
125
  def gather_stale_branch_info
222
126
  stdout, _, success, = git_run( "branch", "-vv" )
223
127
  return { count: 0 } unless success
@@ -226,77 +130,57 @@ module Carson
226
130
  { count: gone_branches.size }
227
131
  end
228
132
 
229
- # Quick governance health check: are templates in sync?
230
- def gather_governance_info
231
- result = with_captured_output { template_check! }
232
- {
233
- templates: result == EXIT_OK ? :in_sync : :drifted
234
- }
235
- rescue StandardError
236
- { templates: :unknown }
237
- end
238
-
239
- # Prints the human-readable status report.
240
133
  def print_status( data: )
241
134
  puts_line "Carson #{data.fetch( :version )}"
242
- puts_line ""
135
+ puts_line "Repository: #{data.dig( :repository, :name )}"
136
+ puts_line "Authority: #{data.dig( :repository, :authority )}"
243
137
 
244
- # Branch
245
138
  branch = data.fetch( :branch )
246
- dirty_marker = branch.fetch( :dirty ) ? " (dirty)" : ""
247
- sync_marker = format_sync( sync: branch.fetch( :sync ) )
248
- puts_line "Branch: #{branch.fetch( :name )}#{dirty_marker}#{sync_marker}"
249
-
250
- # Worktrees
251
- worktrees = data.fetch( :worktrees )
252
- if worktrees.any?
253
- puts_line ""
254
- puts_line "Worktrees:"
255
- worktrees.each do |worktree|
256
- branch_label = worktree.fetch( :branch ) || "(detached)"
257
- puts_line " #{worktree.fetch( :name )} #{branch_label}"
258
- end
139
+ branch_line = "Branch: #{branch.fetch( :name )}"
140
+ branch_line += " (dirty #{branch.fetch( :dirty_reason )})" if branch.fetch( :dirty )
141
+ branch_line += " [#{format_sync( sync: branch.fetch( :sync ) )}]"
142
+ puts_line branch_line
143
+
144
+ deliveries = data.fetch( :branches )
145
+ if deliveries.empty?
146
+ puts_line "Deliveries: none"
147
+ return
259
148
  end
260
149
 
261
- # Pull requests
262
- prs = data.fetch( :pull_requests, nil )
263
- if prs && prs.any?
264
- puts_line ""
265
- puts_line "Pull requests:"
266
- prs.each do |pr|
267
- ci_label = pr.fetch( :ci ).to_s
268
- review_label = pr.fetch( :review ).to_s.tr( "_", " " )
269
- puts_line " ##{pr.fetch( :number )} #{pr.fetch( :title )}"
270
- puts_line " CI: #{ci_label} Review: #{review_label}"
271
- end
150
+ puts_line "Deliveries:"
151
+ deliveries.each do |delivery|
152
+ line = "- #{delivery.fetch( :delivery_state )}: #{delivery.fetch( :branch )}"
153
+ pr_number = delivery.fetch( :pr_number )
154
+ line += " (#{"PR ##{pr_number}"} )" if pr_number
155
+ puts_line line.gsub( " )", ")" )
156
+ puts_line " #{delivery.fetch( :summary )}" unless delivery.fetch( :summary ).to_s.empty?
272
157
  end
158
+ end
273
159
 
274
- # Stale branches
275
- stale = data.fetch( :stale_branches, nil )
276
- if stale && stale.fetch( :count ) > 0
277
- count = stale.fetch( :count )
278
- puts_line ""
279
- puts_line "#{count} stale #{ count == 1 ? 'branch' : 'branches' } ready for pruning."
160
+ def print_portfolio_status( result: )
161
+ if result.fetch( :status ) == "error"
162
+ puts_line "#{result.fetch( :name )}: #{result.fetch( :error )}"
163
+ return
280
164
  end
281
165
 
282
- # Governance
283
- gov = data.fetch( :governance )
284
- templates = gov.fetch( :templates )
285
- unless templates == :in_sync
286
- puts_line ""
287
- puts_line "Templates: #{templates} run `carson sync` to fix."
166
+ deliveries = Array( result.fetch( :branches, [] ) )
167
+ counts = deliveries.each_with_object( Hash.new( 0 ) ) { |delivery, memo| memo[ delivery.fetch( :delivery_state ) ] += 1 }
168
+ summary = if counts.empty?
169
+ "no active deliveries"
170
+ else
171
+ counts.map { |state, count| "#{count} #{state}" }.join( ", " )
288
172
  end
173
+ puts_line "#{result.fetch( :name )}: #{result.dig( :repository, :authority )} — #{summary}"
289
174
  end
290
175
 
291
- # Formats sync status for display.
292
176
  def format_sync( sync: )
293
177
  case sync
294
- when :in_sync then ""
295
- when :ahead then " (ahead of remote)"
296
- when :behind then " (behind remote)"
297
- when :diverged then " (diverged from remote)"
298
- when :no_remote then " (no remote tracking)"
299
- else ""
178
+ when :in_sync then "in sync"
179
+ when :ahead then "ahead"
180
+ when :behind then "behind"
181
+ when :diverged then "diverged"
182
+ when :no_remote then "no remote"
183
+ else "unknown"
300
184
  end
301
185
  end
302
186
  end
@@ -33,15 +33,22 @@ module Carson
33
33
  @config = Config.load( repo_root: repo_root )
34
34
  @git_adapter = Adapters::Git.new( repo_root: repo_root )
35
35
  @github_adapter = Adapters::GitHub.new( repo_root: repo_root )
36
+ @ledger = Ledger.new( path: @config.govern_state_path )
36
37
  @template_sync_result = nil
37
38
  end
38
39
 
39
- attr_reader :template_sync_result
40
+ attr_reader :template_sync_result, :ledger
40
41
 
41
42
  private
42
43
 
43
44
  attr_reader :repo_root, :tool_root, :output, :error, :in, :config, :git_adapter, :github_adapter
44
45
 
46
+ # Ruby 2.6 treats bare `in` awkwardly because of pattern-matching parsing.
47
+ # Keep the original ivar/reader for compatibility, but expose a safe helper name.
48
+ def input_stream
49
+ instance_variable_get( :@in )
50
+ end
51
+
45
52
  # Returns true when full diagnostic output is enabled via --verbose.
46
53
  def verbose?
47
54
  @verbose
@@ -74,6 +81,21 @@ module Carson
74
81
  git_capture!( "rev-parse", "--abbrev-ref", "HEAD" ).strip
75
82
  end
76
83
 
84
+ # Current branch head SHA for delivery identity.
85
+ def current_head
86
+ git_capture!( "rev-parse", "HEAD" ).strip
87
+ end
88
+
89
+ # Passive repository record for the current runtime context.
90
+ def repository_record
91
+ Repository.new( path: repo_root, authority: config.govern_authority, runtime: self )
92
+ end
93
+
94
+ # Passive branch record for the current checkout.
95
+ def branch_record( name: current_branch )
96
+ repository_record.branch( name ).reload
97
+ end
98
+
77
99
  # Checks local branch existence before restore attempts in ensure blocks.
78
100
  def branch_exists?( branch_name: )
79
101
  _, _, success, = git_run( "show-ref", "--verify", "--quiet", "refs/heads/#{branch_name}" )
@@ -310,9 +332,9 @@ module Carson
310
332
  # so only genuinely active worktrees block the operation.
311
333
  scoped_runtime = build_scoped_runtime( repo_path: repo_path )
312
334
  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 }
335
+ worktrees = scoped_runtime.worktree_list
336
+ main_root = scoped_runtime.realpath_safe( repo_path )
337
+ active = worktrees.reject { |worktree| worktree.path == main_root }
316
338
  if active.any?
317
339
  reasons << "#{active.count} active worktree#{active.count == 1 ? '' : 's'}"
318
340
  end
@@ -346,3 +368,16 @@ require_relative "runtime/govern"
346
368
  require_relative "runtime/setup"
347
369
  require_relative "runtime/status"
348
370
  require_relative "runtime/deliver"
371
+
372
+ # Infrastructure interface for domain objects.
373
+ # Carson::Worktree and future domain objects call these methods
374
+ # on a runtime reference — the way ActiveRecord models use a connection.
375
+ # Defined private for internal use, exposed here for domain object access.
376
+ module Carson
377
+ class Runtime
378
+ public :config, :output, :verbose?, :puts_verbose, :puts_line,
379
+ :git_run, :git_capture!, :main_worktree_root, :realpath_safe,
380
+ :block_if_outsider_fingerprints!, :branch_absorbed_into_main?,
381
+ :ledger, :current_head, :repository_record, :branch_record
382
+ end
383
+ end