carson 2.25.0 → 2.27.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6046632b555d3558d7b0b8910cbcb50a562d155b92d535f28275eb78cd9f02d9
4
- data.tar.gz: 8a86dad0adc37689a2dc2185ac428c4710f3aa511b2015455a6eeab9ae7468d6
3
+ metadata.gz: b07e53b67d21543ee131ce2a8bbc906938fbd657f79973aaa59008911dd14df7
4
+ data.tar.gz: 73d0811c01b3daa2f397dd545a266085950d93d4cda474ab3288b6b754cd5d20
5
5
  SHA512:
6
- metadata.gz: aca61a3a47edbbf4a6dea33970b954f5b5116786d3f14713f67e2140ce0d113364f4e5c08181146296cb28858d5023b552102178386e05c6d9120686c79dbcb2
7
- data.tar.gz: 65fd5eb074422776f240a8161c5bec6d3ea6c8d746e182b9e2f95bf8cbd05131c9a0567af5dc0d9ef3f4e5b9bc1ab71e4c22493d3ef69f9a6e43c9487430fac3
6
+ metadata.gz: 9880a422abf831615af431cf9b32b6a28a0cd026f2154a46270664deb4d8d32dacf0dac4912a2e64984889372a43bd3cf36cd1cd87899c7b4e3c5cf7ae989fab
7
+ data.tar.gz: 11e37eb20e6f62dd632493c134218a714dab0a565b533681b1f57b78f28f01dd799786d2d461285dd7623d2832b29f4d3e72f2d95a273f8c80e5869d49f72cd6
data/RELEASE.md CHANGED
@@ -5,6 +5,39 @@ Release-note scope rule:
5
5
  - `RELEASE.md` records only version deltas, breaking changes, and migration actions.
6
6
  - Operational usage guides live in `MANUAL.md` and `API.md`.
7
7
 
8
+ ## 2.27.0 — Absorbed Branch Pruning
9
+
10
+ ### What changed
11
+
12
+ - **`carson prune` now detects and removes absorbed branches.** An absorbed branch is one whose upstream still exists but whose content is already on main — every file the branch changed has identical content on main. This catches branches whose work landed via a different PR, cherry-pick, or independent re-implementation.
13
+ - Detection uses a two-step evidence check: (1) find the merge-base, (2) compare only the files the branch changed. If all are identical on main, the branch is absorbed.
14
+ - Fast path: branches that are strict ancestors of main (fully merged via fast-forward) are also detected.
15
+ - Safety: absorbed branches are only deleted when no open PR exists. If `gh` is unavailable, absorbed detection is skipped entirely.
16
+ - Both local and remote branches are cleaned up.
17
+
18
+ ### Why
19
+
20
+ Carson already pruned stale branches (upstream deleted) and orphan branches (no tracking, merged PR evidence). But branches whose remote still existed and had tracking were invisible to prune — even when their content had already landed on main through other means. This gap left repositories cluttered with dead branches that required manual investigation to clean up.
21
+
22
+ ### Migration
23
+
24
+ No action required. `carson prune` gains the new detection automatically.
25
+
26
+ ## 2.26.0 — Baseline Check No Longer Blocks Commits
27
+
28
+ ### What changed
29
+
30
+ - **Default-branch CI baseline failures are now advisory, not blocking.** When main's CI is failing, `carson audit` reports it as `attention` instead of `block`. This eliminates the deadlock where a broken main prevented committing the fix on any branch. The baseline status is still reported so users are aware before merging.
31
+ - Message changed from "merge blocked" to "fix before merge" to reflect the advisory nature.
32
+
33
+ ### Why
34
+
35
+ Carson's pre-commit hook runs `audit`, which checks main's CI baseline. If main had a failing check, the audit hard-blocked all commits across every branch — including the branch carrying the fix. This circular dependency made it impossible to commit the repair without bypassing the hook entirely.
36
+
37
+ ### Migration
38
+
39
+ No action required. The `CARSON_AUDIT_ADVISORY_CHECK_NAMES` env var workaround is no longer needed for this scenario.
40
+
8
41
  ## 2.25.0 — Onboard/Offboard UX Improvements
9
42
 
10
43
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.25.0
1
+ 2.27.0
@@ -75,7 +75,6 @@ module Carson
75
75
  puts_verbose ""
76
76
  puts_verbose "[Default Branch CI Baseline (gh)]"
77
77
  default_branch_baseline = default_branch_ci_baseline_report
78
- audit_state = "block" if default_branch_baseline.fetch( :status ) == "block"
79
78
  audit_state = "attention" if audit_state == "ok" && default_branch_baseline.fetch( :status ) != "ok"
80
79
  baseline_st = default_branch_baseline.fetch( :status )
81
80
  if baseline_st == "block"
@@ -83,7 +82,7 @@ module Carson
83
82
  parts << "#{default_branch_baseline.fetch( :failing_count )} failing" if default_branch_baseline.fetch( :failing_count ).positive?
84
83
  parts << "#{default_branch_baseline.fetch( :pending_count )} pending" if default_branch_baseline.fetch( :pending_count ).positive?
85
84
  parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
86
- audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — merge blocked."
85
+ audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — fix before merge."
87
86
  elsif baseline_st == "attention"
88
87
  parts = []
89
88
  parts << "#{default_branch_baseline.fetch( :advisory_failing_count )} advisory failing" if default_branch_baseline.fetch( :advisory_failing_count ).positive?
@@ -1,7 +1,8 @@
1
1
  module Carson
2
2
  class Runtime
3
3
  module Local
4
- # Removes stale local branches (gone upstream) and orphan branches (no tracking) with merged PR evidence.
4
+ # Removes stale local branches (gone upstream), orphan branches (no tracking) with merged PR evidence,
5
+ # and absorbed branches (content already on main, no open PR).
5
6
  def prune!
6
7
  fingerprint_status = block_if_outsider_fingerprints!
7
8
  return fingerprint_status unless fingerprint_status.nil?
@@ -16,6 +17,9 @@ module Carson
16
17
  orphan_branches = orphan_local_branches( active_branch: active_branch )
17
18
  prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )
18
19
 
20
+ absorbed_branches = absorbed_local_branches( active_branch: active_branch )
21
+ prune_absorbed_branch_entries( absorbed_branches: absorbed_branches, counters: counters )
22
+
19
23
  return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?
20
24
 
21
25
  puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
@@ -147,6 +151,107 @@ module Carson
147
151
  end
148
152
  end
149
153
 
154
+ # Detects local branches whose upstream still exists but whose content is already on main.
155
+ # Two-step evidence: (1) find the merge-base, (2) verify every file the branch changed
156
+ # relative to the merge-base has identical content on main.
157
+ def absorbed_local_branches( active_branch: )
158
+ git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.filter_map do |line|
159
+ branch, upstream, track = line.strip.split( "\t", 3 )
160
+ branch = branch.to_s.strip
161
+ upstream = upstream.to_s.strip
162
+ track = track.to_s
163
+ next if branch.empty?
164
+ next if upstream.empty?
165
+ next if track.include?( "gone" )
166
+ next if config.protected_branches.include?( branch )
167
+ next if branch == active_branch
168
+ next if branch == TEMPLATE_SYNC_BRANCH
169
+
170
+ next unless branch_absorbed_into_main?( branch: branch )
171
+
172
+ { branch: branch, upstream: upstream }
173
+ end
174
+ end
175
+
176
+ # Returns true when the branch has no unique content relative to main.
177
+ def branch_absorbed_into_main?( branch: )
178
+ # Fast path: branch is a strict ancestor of main (fully merged).
179
+ _, _, is_ancestor, = git_run( "merge-base", "--is-ancestor", branch, config.main_branch )
180
+ return true if is_ancestor
181
+
182
+ # Find the merge-base between main and the branch.
183
+ merge_base_text, _, mb_success, = git_run( "merge-base", config.main_branch, branch )
184
+ return false unless mb_success
185
+
186
+ merge_base = merge_base_text.to_s.strip
187
+ return false if merge_base.empty?
188
+
189
+ # List every file the branch changed relative to the merge-base.
190
+ changed_text, _, changed_success, = git_run( "diff", "--name-only", merge_base, branch )
191
+ return false unless changed_success
192
+
193
+ changed_files = changed_text.to_s.strip.lines.map( &:strip ).reject( &:empty? )
194
+ return true if changed_files.empty?
195
+
196
+ # Compare only those files between branch tip and main tip.
197
+ # If identical, every branch change is already on main.
198
+ _, _, identical, = git_run( "diff", "--quiet", branch, config.main_branch, "--", *changed_files )
199
+ identical
200
+ end
201
+
202
+ # Processes absorbed branches: verifies no open PR exists before deleting local and remote.
203
+ def prune_absorbed_branch_entries( absorbed_branches:, counters: )
204
+ return counters if absorbed_branches.empty?
205
+ return counters unless gh_available?
206
+
207
+ absorbed_branches.each do |entry|
208
+ outcome = prune_absorbed_branch_entry( branch: entry.fetch( :branch ), upstream: entry.fetch( :upstream ) )
209
+ counters[ outcome ] += 1
210
+ end
211
+ counters
212
+ end
213
+
214
+ # Checks a single absorbed branch for open PRs and deletes local + remote if safe.
215
+ def prune_absorbed_branch_entry( branch:, upstream: )
216
+ if branch_has_open_pr?( branch: branch )
217
+ puts_verbose "skip_absorbed_branch: #{branch} reason=open PR exists"
218
+ return :skipped
219
+ end
220
+
221
+ force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
222
+ unless force_success
223
+ error_text = normalise_branch_delete_error( error_text: force_stderr )
224
+ puts_verbose "fail_delete_absorbed_branch: #{branch} reason=#{error_text}"
225
+ return :skipped
226
+ end
227
+
228
+ out.print force_stdout if verbose? && !force_stdout.empty?
229
+
230
+ remote_branch = upstream.sub( "#{config.git_remote}/", "" )
231
+ git_run( "push", config.git_remote, "--delete", remote_branch )
232
+
233
+ puts_verbose "deleted_absorbed_branch: #{branch} (upstream=#{upstream})"
234
+ :deleted
235
+ end
236
+
237
+ # Returns true if the branch has at least one open PR.
238
+ def branch_has_open_pr?( branch: )
239
+ owner, repo = repository_coordinates
240
+ stdout_text, _, success, = gh_run(
241
+ "api", "repos/#{owner}/#{repo}/pulls",
242
+ "--method", "GET",
243
+ "-f", "state=open",
244
+ "-f", "head=#{owner}:#{branch}",
245
+ "-f", "per_page=1"
246
+ )
247
+ return true unless success
248
+
249
+ results = Array( JSON.parse( stdout_text ) )
250
+ !results.empty?
251
+ rescue StandardError
252
+ true
253
+ end
254
+
150
255
  # Processes orphan branches: verifies merged PR evidence via GitHub API before deleting.
151
256
  def prune_orphan_branch_entries( orphan_branches:, counters: )
152
257
  return counters if orphan_branches.empty?
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: 2.25.0
4
+ version: 2.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang