carson 4.0.1 → 4.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b65ac7c43c1275299430d71f23f1da3a9896b112aa1f57e334222d06282a3a62
4
- data.tar.gz: ff4efcd085b8cb0deb4385bfdb40d98407d463547b6e065fbfc455f516cc4dbb
3
+ metadata.gz: a5678a9b3d25a0a0c6e2b37182e53607b8c41e0a199771bbae7535ec776f71c8
4
+ data.tar.gz: c744e380475f851bdc142dc13693f3b35a229b93a45fdbc2ecb70e33fd3ba8cc
5
5
  SHA512:
6
- metadata.gz: b49a0b8760ad230996f6330b378fd7f9a202977c148683c4ae3eec066f2b820f6210e59a0e251ef275471d90db0d8154dee82cc5f97c3e46378cfad1fce074c2
7
- data.tar.gz: 64cc150e7bb257ebbd6d48a603ab7eaa3e3b9794f08fcc65625bb8264628cc99e14f5603fe526d7818253ac57169de995cdc71c413c75cc6a3e2b56dbbb41f2c
6
+ metadata.gz: 136aeaa5ebf42de0f676875a44d8d4bf2eb758b9d70753718ba6cb27ca37756ca42af3518247e1a357c6e5a3bb8cdaa9bbdeff2d1fa9f3fd63302263b4d3c9a1
7
+ data.tar.gz: db5026a393f292c7dee8e1a6b3d63ec4463bcaebea179161ceb184271826d0c1a803bfc9a6a31f6cc5d7446f10be3e52739bebfbb70c548a8cb607fd1f57e4ae
data/RELEASE.md CHANGED
@@ -11,6 +11,23 @@ Release-note scope rule:
11
11
 
12
12
  - CLI grammar is now two-tier: portfolio commands (`list`, `onboard`, `offboard`, `refresh`, `version`) and repo-scoped commands (`carson <repo> <command>` or `carson <command>` from CWD)
13
13
 
14
+ ## 4.0.3
15
+
16
+ ### Fixed
17
+
18
+ - **No-checks repos can now merge** (#465) — `Waybill#cleared?` required CI to pass, but repos with no CI checks returned `:none`. These repos were permanently held. Now `:none` is treated as non-blocking.
19
+ - **Sync failure reported explicitly** — When local main fails to sync after a merge, `carson deliver` now prints "Local main not synced — run carson sync." instead of silently omitting the line.
20
+ - **Freshness fetch failure blocks delivery** — `Courier#deliver` ignored the return value of `fetch_latest`. If the fetch failed (network error), Carson reasoned from stale refs. Now blocks immediately with "cannot verify freshness — fetch failed" and Carson-first recovery.
21
+ - **PR data reaches the ledger** — The Courier's `record` method hardcoded `pr_number: nil` and `pr_url: nil`. Filed deliveries had no PR reference in the ledger. Now passes waybill tracking number and URL.
22
+
23
+ ## 4.0.2
24
+
25
+ ### Fixed
26
+
27
+ - **Seal marker moved outside worktree** — `.carson-delivering` marker now lives at `~/.carson/seals/` instead of inside the worktree. The in-worktree marker polluted `git status`, showed as untracked in editors, and blocked `carson deliver` via the dirty-tree guard. Seal file includes the worktree path for debugging.
28
+ - **`receive_latest_standard!` works from worktrees** (#451) — `git fetch origin main:main` failed when `main` was checked out in the main worktree ("fatal: refusing to fetch into branch checked out"). Now uses `merge --ff-only` when main is checked out, fetch refspec otherwise.
29
+ - **Audit seal check delegates to Warehouse** — `carson audit` no longer hardcodes the marker path; it creates a Warehouse and asks `sealed?`. Single source of truth for seal location.
30
+
14
31
  ## 4.0.1
15
32
 
16
33
  ### What changed
@@ -34,7 +51,7 @@ Release-note scope rule:
34
51
 
35
52
  - **`carson deliver` waits for CI and merges automatically** — `carson deliver` now polls GitHub up to 6 times (default 30s interval) instead of checking once and returning immediately. PRs that pass CI are merged without re-running `carson deliver`. Agents that relied on instant return should expect it to block for up to 3 minutes.
36
53
  - **Hold reasons renamed** — JSON output field `hold_reason` values changed: `inspector_pending` → `pending_at_registry`, `inspector_failed` → `failed_at_registry`, `inspector_error` → `error_at_registry`. Agents parsing these values must update.
37
- - **Hold messages include recovery commands** — Human output for held deliveries now shows actionable next steps (e.g. `→ git rebase origin/main` then `→ carson deliver`) instead of prose descriptions.
54
+ - **Hold messages include recovery commands** — Human output for held deliveries now shows actionable next steps (e.g. `→ carson deliver`) instead of prose descriptions.
38
55
 
39
56
  ### New
40
57
 
@@ -63,7 +80,8 @@ Release-note scope rule:
63
80
 
64
81
  ### What changed
65
82
 
66
- - **Recovery hints re-enter through Carson, not blocked raw commands** — Delivery error recovery messages no longer suggest raw `gh pr create`, `gh pr merge`, or `git rebase` commands. All recovery paths now guide the user back through Carson. Freshness blocks say "refresh this branch onto the target, then `carson deliver`" instead of teaching raw git recipes.
83
+ - **Recovery hints re-enter through Carson, not blocked raw commands** — Delivery error recovery messages no longer suggest raw `gh pr create` or `gh pr merge`. All recovery paths guide the user back through Carson.
84
+ - **Auto-rebase on delivery** — `carson deliver` automatically rebases the branch when it's behind remote main. Only blocks if the rebase hits a conflict. No more manual `git rebase` before delivery.
67
85
  - **Fast PR indentation guard restored** — The Ruby indentation guard in CI now correctly handles access modifier detection, fixing false positives that blocked PRs.
68
86
 
69
87
  ### No migration required
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.0.1
1
+ 4.0.3
@@ -125,12 +125,21 @@ module Carson
125
125
  parcel = Parcel.new( label: parcel.label, head: @warehouse.current_head, shelf: parcel.shelf )
126
126
 
127
127
  # 02. Parcel behind standard — not based on client's latest standard.
128
- @warehouse.fetch_latest( registry: @warehouse.main_label )
128
+ # The courier rebases automatically. Only blocks on conflict.
129
+ unless @warehouse.fetch_latest( registry: @warehouse.main_label )
130
+ return blocked( result,
131
+ "cannot verify freshness — fetch failed",
132
+ recovery: "carson sync, then carson deliver" )
133
+ end
129
134
  unless @warehouse.based_on_latest_standard?( parcel )
130
135
  remote_main = "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
131
- return blocked( result,
132
- "branch is behind #{remote_main}",
133
- recovery: "git rebase #{remote_main}, then carson deliver" )
136
+ say "Branch is behind #{remote_main} — rebasing..."
137
+ unless @warehouse.rebase_on_latest_standard!
138
+ return blocked( result,
139
+ "rebase conflict onto #{remote_main}",
140
+ recovery: "resolve conflicts, then carson deliver" )
141
+ end
142
+ parcel = Parcel.new( label: parcel.label, head: @warehouse.current_head, shelf: parcel.shelf )
134
143
  end
135
144
 
136
145
  # Announce the delivery.
@@ -172,7 +181,7 @@ module Carson
172
181
  @warehouse.unseal_shelf! if outcome == "delivered" || outcome == "held" || outcome == "rejected"
173
182
 
174
183
  # Update the ledger with the final outcome.
175
- record( parcel, status: outcome || "filed", summary: result[ :hold_reason ] )
184
+ record( parcel, status: outcome || "filed", summary: result[ :hold_reason ], waybill: waybill )
176
185
 
177
186
  result[ :exit ] ||= OK
178
187
  result
@@ -254,7 +263,7 @@ module Carson
254
263
 
255
264
  # Record a delivery state change in the ledger.
256
265
  # No-op when no ledger is injected (e.g. tests).
257
- def record( parcel, status:, summary: nil )
266
+ def record( parcel, status:, summary: nil, waybill: nil )
258
267
  return unless @ledger
259
268
 
260
269
  # The ledger needs a repository-like object with .path pointing
@@ -265,8 +274,8 @@ module Carson
265
274
  branch_name: parcel.label,
266
275
  head: parcel.head,
267
276
  worktree_path: @warehouse.path,
268
- pr_number: nil,
269
- pr_url: nil,
277
+ pr_number: waybill&.tracking_number,
278
+ pr_url: waybill&.url,
270
279
  status: status,
271
280
  summary: summary,
272
281
  cause: nil
@@ -1,7 +1,7 @@
1
1
  # Passive ledger record for one branch delivery attempt.
2
2
  module Carson
3
3
  class Delivery
4
- ACTIVE_STATES = %w[preparing gated queued integrating escalated].freeze
4
+ ACTIVE_STATES = %w[preparing gated queued integrating escalated filed].freeze
5
5
  BLOCKED_STATES = %w[gated escalated].freeze
6
6
  READY_STATES = %w[queued].freeze
7
7
  TERMINAL_STATES = %w[integrated failed superseded].freeze
@@ -60,6 +60,10 @@ module Carson
60
60
  READY_STATES.include?( status )
61
61
  end
62
62
 
63
+ def filed?
64
+ status == "filed"
65
+ end
66
+
63
67
  def integrated?
64
68
  status == "integrated"
65
69
  end
@@ -185,11 +185,12 @@ module Carson
185
185
 
186
186
  # Check if the workbench is sealed (parcel in flight).
187
187
  # Returns EXIT_BLOCK if sealed, nil otherwise.
188
+ # Delegates to Warehouse so the seal path is resolved in one place.
188
189
  def audit_sealed_workbench( json_output: )
189
- marker_path = File.join( work_dir, ".carson-delivering" )
190
- return nil unless File.exist?( marker_path )
190
+ warehouse = Warehouse.new( path: work_dir )
191
+ return nil unless warehouse.sealed?
191
192
 
192
- tracking_number = File.read( marker_path ).strip rescue "unknown"
193
+ tracking_number = warehouse.sealed_tracking_number || "unknown"
193
194
  if json_output
194
195
  require "json"
195
196
  output.puts JSON.pretty_generate( {
@@ -231,7 +231,7 @@ module Carson
231
231
  when :blocked
232
232
  result[ :outcome ] = "blocked"
233
233
  result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
234
- result[ :recovery ] = "refresh this branch onto #{config.git_remote}/#{main}, then carson deliver" if evaluation[ :cause ] == "freshness"
234
+ result[ :recovery ] = "carson deliver" if evaluation[ :cause ] == "freshness"
235
235
  apply_handoff!(
236
236
  result: result,
237
237
  reason: evaluation.fetch( :reason ),
@@ -752,7 +752,7 @@ module Carson
752
752
 
753
753
  def freshness_recovery( freshness: )
754
754
  remote_ref = freshness.fetch( :remote_ref )
755
- return "refresh this branch onto #{remote_ref}, then carson deliver" if freshness.fetch( :status ) == :behind
755
+ return "carson deliver" if freshness.fetch( :status ) == :behind
756
756
 
757
757
  "carson deliver (once #{remote_ref} is reachable)"
758
758
  end
@@ -840,7 +840,7 @@ module Carson
840
840
  reason: "freshness_behind",
841
841
  cause: "freshness",
842
842
  summary: "branch is behind #{remote_main}",
843
- recovery: "refresh this branch onto #{remote_main}, then carson deliver"
843
+ recovery: "carson deliver"
844
844
  } if merge_state == "BEHIND"
845
845
 
846
846
  return {
@@ -2,6 +2,13 @@
2
2
  module Carson
3
3
  class Runtime
4
4
  module Local
5
+ # Refreshes hooks only — safe to run regardless of worktree or
6
+ # uncommitted-changes state, because hooks write to ~/.carson/hooks/
7
+ # and only touch .git/config, not the working tree.
8
+ def refresh_hooks!
9
+ prepare!
10
+ end
11
+
5
12
  private
6
13
 
7
14
  # Installs required hook files and enforces repository hook path.
@@ -120,8 +120,14 @@ module Carson
120
120
 
121
121
  safety = portfolio_repo_safety( repo_path: repo_path )
122
122
  unless safety.fetch( :safe )
123
+ # Hooks write to ~/.carson/hooks/, not the repo — always safe to refresh.
124
+ hooks_status = refresh_hooks_single_repo( repo_path: repo_path )
123
125
  reason = safety.fetch( :reasons ).join( ", " )
124
- puts_line "#{repo_name}: PENDING (#{reason})"
126
+ if hooks_status == EXIT_OK
127
+ puts_line "#{repo_name}: hooks refreshed, templates pending (#{reason})"
128
+ else
129
+ puts_line "#{repo_name}: PENDING (#{reason})"
130
+ end
125
131
  record_batch_skip( command: "refresh", repo_path: repo_path, reason: reason )
126
132
  pending += 1
127
133
  next
@@ -304,6 +310,16 @@ module Carson
304
310
  EXIT_ERROR
305
311
  end
306
312
 
313
+ # Refreshes hooks only for a governed repo using a scoped Runtime.
314
+ # Used when the full refresh is blocked by active worktrees or uncommitted
315
+ # changes — hooks write to ~/.carson/hooks/ and do not touch the working tree.
316
+ def refresh_hooks_single_repo( repo_path: )
317
+ scoped_runtime = build_scoped_runtime( repo_path: repo_path )
318
+ scoped_runtime.refresh_hooks!
319
+ rescue StandardError
320
+ EXIT_ERROR
321
+ end
322
+
307
323
  def refresh_status_label( status: )
308
324
  case status
309
325
  when EXIT_OK then "OK"
@@ -74,7 +74,23 @@ module Carson
74
74
 
75
75
  puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}" unless silent
76
76
 
77
+ # Collect worktree paths of filed deliveries before reconciliation.
78
+ # Receive takes over lifecycle management — the courier's polling window is over.
79
+ filed_worktree_paths = deliveries
80
+ .select( &:filed? )
81
+ .map( &:worktree_path )
82
+ .compact
83
+ .reject { |path| path.to_s.strip.empty? }
84
+
77
85
  reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
86
+
87
+ # Unseal worktrees that were filed — receive now owns the delivery lifecycle.
88
+ unless dry_run
89
+ filed_worktree_paths.each do |worktree_path|
90
+ Warehouse.new( path: worktree_path ).unseal_shelf!
91
+ end
92
+ end
93
+
78
94
  next_to_integrate = reconciled.find( &:ready? )&.key
79
95
 
80
96
  reconciled.each do |delivery|
@@ -2,6 +2,8 @@
2
2
  # parcels (committed changes) are stored on shelves (worktrees) with
3
3
  # labels (branches). Git commands are hidden inside — callers never
4
4
  # see git terms.
5
+ require "digest"
6
+ require "fileutils"
5
7
  require "open3"
6
8
 
7
9
  module Carson
@@ -107,8 +109,12 @@ module Carson
107
109
 
108
110
  # Seal the shelf — no more packing until the delivery outcome is confirmed.
109
111
  # The courier seals the shelf after shipping and filing the waybill.
112
+ # The marker lives outside the worktree (~/.carson/seals/) so it does
113
+ # not pollute git status or block delivery with a dirty-tree guard.
110
114
  def seal_shelf!( tracking_number: )
111
- File.write( delivering_marker_path, tracking_number.to_s )
115
+ marker = delivering_marker_path
116
+ FileUtils.mkdir_p( File.dirname( marker ) )
117
+ File.write( marker, "#{tracking_number}\n#{@path}" )
112
118
  end
113
119
 
114
120
  # Unseal the shelf — the courier brought back the parcel.
@@ -125,18 +131,43 @@ module Carson
125
131
  # The tracking number of the in-flight delivery (nil if not sealed).
126
132
  def sealed_tracking_number
127
133
  return nil unless sealed?
128
- File.read( delivering_marker_path ).strip
134
+ File.read( delivering_marker_path ).lines.first.strip
129
135
  end
130
136
 
131
137
  # Receive the latest standard from the registry after a parcel is accepted.
132
138
  # Fast-forwards local main without switching branches.
133
139
  # Returns true on success, false on failure.
140
+ #
141
+ # Two paths depending on the main worktree's checkout state:
142
+ # - Main checked out → merge --ff-only (updates ref + working tree).
143
+ # - Main not checked out → fetch refspec (updates ref only, safe when
144
+ # no worktree has the branch).
134
145
  def receive_latest_standard!( remote: bureau_address )
135
- _, _, status = Open3.capture3(
136
- "git", "-C", main_worktree_root,
137
- "fetch", remote, "#{main_label}:#{main_label}"
146
+ root = main_worktree_root
147
+
148
+ # Fetch remote tracking refs — always safe, even when main is checked out.
149
+ _, _, fetch_status = Open3.capture3( "git", "-C", root, "fetch", remote )
150
+ return false unless fetch_status.success?
151
+
152
+ # Determine how to advance local main.
153
+ head_ref, _, head_status = Open3.capture3(
154
+ "git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"
138
155
  )
139
- status.success?
156
+ return false unless head_status.success?
157
+
158
+ if head_ref.strip == @main_label
159
+ # Main is checked out in the main worktree — fast-forward via merge.
160
+ _, _, merge_status = Open3.capture3(
161
+ "git", "-C", root, "merge", "--ff-only", "#{remote}/#{@main_label}"
162
+ )
163
+ merge_status.success?
164
+ else
165
+ # Main is not checked out — safe to update the ref via fetch refspec.
166
+ _, _, refspec_status = Open3.capture3(
167
+ "git", "-C", root, "fetch", remote, "#{@main_label}:#{@main_label}"
168
+ )
169
+ refspec_status.success?
170
+ end
140
171
  end
141
172
 
142
173
  # --- Inventory ---
@@ -173,8 +204,12 @@ module Carson
173
204
  private
174
205
 
175
206
  # Path to the delivery marker file — signals the shelf is sealed.
207
+ # Lives outside the worktree at ~/.carson/seals/<sha256-of-path>
208
+ # so it does not pollute git status.
176
209
  def delivering_marker_path
177
- File.join( path, ".carson-delivering" )
210
+ seals_dir = File.join( Dir.home, ".carson", "seals" )
211
+ key = Digest::SHA256.hexdigest( @path )
212
+ File.join( seals_dir, key )
178
213
  end
179
214
 
180
215
  # All git commands go through this single gateway.
@@ -98,7 +98,7 @@ module Carson
98
98
  def cleared?
99
99
  return false unless filed?
100
100
  return false if draft?
101
- return false unless @ci == :pass
101
+ return false unless @ci == :pass || @ci == :none
102
102
  return false if merge_conflicting? || merge_behind? || merge_policy_blocked?
103
103
  merge_status = @state&.dig( "mergeStateStatus" ).to_s.upcase
104
104
  mergeable = @state&.dig( "mergeable" ).to_s.upcase
data/lib/carson.rb CHANGED
@@ -34,7 +34,11 @@ module Carson
34
34
  case result[ :outcome ]
35
35
  when "delivered"
36
36
  output.puts "#{BADGE} Merged."
37
- output.puts "#{BADGE} Local main synced." if result[ :synced ]
37
+ if result[ :synced ]
38
+ output.puts "#{BADGE} Local main synced."
39
+ elsif result.key?( :synced )
40
+ output.puts "#{BADGE} Local main not synced \u2014 run carson sync."
41
+ end
38
42
  when "held"
39
43
  diagnosis, *recovery_steps = translate_hold( result[ :hold_reason ], remote_main: remote_main )
40
44
  output.puts "#{BADGE} #{diagnosis}"
@@ -64,7 +68,7 @@ module Carson
64
68
  when "merge_conflict"
65
69
  [ "Merge conflict with #{remote_main}.", "git rebase #{remote_main}", "carson deliver" ]
66
70
  when "behind_registry"
67
- [ "Branch is behind #{remote_main}.", "git rebase #{remote_main}", "carson deliver" ]
71
+ [ "Branch is behind #{remote_main}.", "carson deliver" ]
68
72
  when "policy_block"
69
73
  [ "Blocked by branch protection rules." ]
70
74
  when "mergeability_pending"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.1
4
+ version: 4.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang