carson 2.26.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 +4 -4
- data/RELEASE.md +18 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/local/prune.rb +106 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b07e53b67d21543ee131ce2a8bbc906938fbd657f79973aaa59008911dd14df7
|
|
4
|
+
data.tar.gz: 73d0811c01b3daa2f397dd545a266085950d93d4cda474ab3288b6b754cd5d20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9880a422abf831615af431cf9b32b6a28a0cd026f2154a46270664deb4d8d32dacf0dac4912a2e64984889372a43bd3cf36cd1cd87899c7b4e3c5cf7ae989fab
|
|
7
|
+
data.tar.gz: 11e37eb20e6f62dd632493c134218a714dab0a565b533681b1f57b78f28f01dd799786d2d461285dd7623d2832b29f4d3e72f2d95a273f8c80e5869d49f72cd6
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,24 @@ 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
|
+
|
|
8
26
|
## 2.26.0 — Baseline Check No Longer Blocks Commits
|
|
9
27
|
|
|
10
28
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.27.0
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module Carson
|
|
2
2
|
class Runtime
|
|
3
3
|
module Local
|
|
4
|
-
# Removes stale local branches (gone upstream)
|
|
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?
|