carson 2.19.0 → 2.20.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: b6b2ecf51c931d6c85244d96e294ce87a86583e3a807782ddd74c9058c647563
4
- data.tar.gz: 19b49e67b2bdefe3443aa00500394cc479951eb074498d82b69ff3f776b42b15
3
+ metadata.gz: 1baa45e25838165768104a260d5641a4f3cfe25fbfe3f7c1d430a9d62dc53cf1
4
+ data.tar.gz: 99ea0d1c0ba2b332cd10c599ea472f8b2d0b1ca016bfc73ec879f421b32dffae
5
5
  SHA512:
6
- metadata.gz: acc11f185996f6e424b18bbc55ed6194ae87d31db6de09b9b34fc50237c87ada00ab8c1c3809cf9d0b3654353e24cf1a2fb1b9fd6134610a57ded49d4fbe1a5c
7
- data.tar.gz: c27ac61fba18b5cf449a33b8703337282b5a30f0cf87568e24320acd56b030b57093ba8d24466c92450745dc61f0eaa83f2c5db603cd242e0e6b2413233331d0
6
+ metadata.gz: f746f1fb2d74940e0e5b21d6ab0a9d5857aa78972453202016345214b5b77504792ab490ac5afda8028b1b2b07618d0451c4a996a004ca2e1908c29a3de82c78
7
+ data.tar.gz: 95ad47ab82a4a617d6fe590c2ead6e15c8b11e36695e0039447158f35c07790fa001997bfc180f56cc441387a32e259af19d0ef02b3d01580d9a449d1ff337ee
data/MANUAL.md CHANGED
@@ -101,7 +101,7 @@ Notes:
101
101
 
102
102
  ### Canonical Templates
103
103
 
104
- Carson manages 5 governance files (carson.md, CLAUDE.md, AGENTS.md, copilot-instructions.md, pull_request_template.md). Beyond those, you can tell Carson about your own canonical `.github/` files — CI workflows, linter configs, dependabot settings, anything that belongs in `.github/`.
104
+ Carson manages 5 governance files (carson.md, CLAUDE.md, AGENTS.md, copilot-instructions.md, pull_request_template.md). Beyond those, you can tell Carson about your own canonical `.github/` files — CI workflows, linter configs, labeller rules, anything that belongs in `.github/`.
105
105
 
106
106
  Set `template.canonical` in `~/.carson/config.json`:
107
107
 
@@ -120,7 +120,7 @@ That directory mirrors the `.github/` structure:
120
120
  ├── workflows/
121
121
  │ └── lint.yml → deployed to .github/workflows/lint.yml
122
122
  ├── .mega-linter.yml → deployed to .github/.mega-linter.yml
123
- └── dependabot.yml → deployed to .github/dependabot.yml
123
+ └── labeler.yml → deployed to .github/labeler.yml
124
124
  ```
125
125
 
126
126
  Carson discovers files in this directory and syncs them to governed repos alongside its own governance files. `carson template check` detects drift, `carson template apply` writes them, and `carson refresh` propagates them to the remote.
data/RELEASE.md CHANGED
@@ -5,6 +5,20 @@ 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.20.0 — Prune Orphan Branches
9
+
10
+ ### What changed
11
+
12
+ - **`carson prune` now detects orphan branches.** Local branches with no upstream tracking are pruned when GitHub confirms a merged PR matching the exact branch name and tip SHA. Previously, only branches with `[gone]` upstream tracking were detected — branches that were never pushed with `-u` or lost tracking would linger indefinitely.
13
+ - Orphan deletions count towards the same "Pruned N stale branches." total in concise output. Verbose output uses distinct `deleted_orphan_branch:` / `skip_orphan_branch:` log lines.
14
+ - Carson's own `carson/template-sync` branch and protected branches are excluded from orphan detection.
15
+
16
+ ## 2.19.1 — Remove Dependabot References
17
+
18
+ ### What changed
19
+
20
+ - Replaced all Dependabot example references in documentation and tests with `labeler.yml`. Carson never had a Dependabot feature — these were illustrative filenames for the canonical template system.
21
+
8
22
  ## 2.19.0 — Canonical Templates, Lint Removed
9
23
 
10
24
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.19.0
1
+ 2.20.0
@@ -34,35 +34,45 @@ module Carson
34
34
  git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
35
35
  end
36
36
 
37
- # Removes stale local branches that track remote refs already deleted upstream.
37
+ # Removes stale local branches (gone upstream) and orphan branches (no tracking) with merged PR evidence.
38
38
  def prune!
39
39
  fingerprint_status = block_if_outsider_fingerprints!
40
40
  return fingerprint_status unless fingerprint_status.nil?
41
41
 
42
42
  git_system!( "fetch", config.git_remote, "--prune" )
43
43
  active_branch = current_branch
44
+ counters = { deleted: 0, skipped: 0 }
45
+
44
46
  stale_branches = stale_local_branches
45
- return prune_no_stale_branches if stale_branches.empty?
47
+ prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters )
48
+
49
+ orphan_branches = orphan_local_branches( active_branch: active_branch )
50
+ prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )
51
+
52
+ return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?
46
53
 
47
- counters = prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch )
48
54
  puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
49
55
  unless verbose?
50
- puts_line "Pruned #{counters.fetch( :deleted )} stale branch#{plural_suffix( count: counters.fetch( :deleted ) )}."
56
+ deleted_count = counters.fetch( :deleted )
57
+ if deleted_count.zero?
58
+ puts_line "No stale branches."
59
+ else
60
+ puts_line "Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
61
+ end
51
62
  end
52
63
  EXIT_OK
53
64
  end
54
65
 
55
66
  def prune_no_stale_branches
56
67
  if verbose?
57
- puts_line "OK: no stale local branches tracking deleted #{config.git_remote} branches."
68
+ puts_line "OK: no stale or orphan branches to prune."
58
69
  else
59
70
  puts_line "No stale branches."
60
71
  end
61
72
  EXIT_OK
62
73
  end
63
74
 
64
- def prune_stale_branch_entries( stale_branches:, active_branch: )
65
- counters = { deleted: 0, skipped: 0 }
75
+ def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 } )
66
76
  stale_branches.each do |entry|
67
77
  outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
68
78
  counters[ outcome ] += 1
@@ -851,10 +861,8 @@ module Carson
851
861
  violations
852
862
  end
853
863
 
854
- # NOTE: prune only targets local branches that meet both conditions:
855
- # 1) branch tracks configured remote (`github/*` by default), and
856
- # 2) upstream tracking state is marked as gone after fetch --prune.
857
- # Branches without upstream tracking are intentionally excluded.
864
+ # Detects local branches whose upstream tracking is marked [gone] after fetch --prune.
865
+ # Branches without upstream tracking are handled separately by orphan_local_branches.
858
866
  def stale_local_branches
859
867
  git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
860
868
  branch, upstream, track = line.strip.split( "\t", 3 )
@@ -867,6 +875,70 @@ module Carson
867
875
  end.compact
868
876
  end
869
877
 
878
+ # Detects local branches with no upstream tracking ref — candidates for orphan pruning.
879
+ # Filters out protected branches, the active branch, and Carson's own sync branch.
880
+ def orphan_local_branches( active_branch: )
881
+ git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.filter_map do |line|
882
+ branch, upstream = line.strip.split( "\t", 2 )
883
+ branch = branch.to_s.strip
884
+ upstream = upstream.to_s.strip
885
+ next if branch.empty?
886
+ next unless upstream.empty?
887
+ next if config.protected_branches.include?( branch )
888
+ next if branch == active_branch
889
+ next if branch == TEMPLATE_SYNC_BRANCH
890
+
891
+ branch
892
+ end
893
+ end
894
+
895
+ # Processes orphan branches: verifies merged PR evidence via GitHub API before deleting.
896
+ def prune_orphan_branch_entries( orphan_branches:, counters: )
897
+ return counters if orphan_branches.empty?
898
+ return counters unless gh_available?
899
+
900
+ orphan_branches.each do |branch|
901
+ outcome = prune_orphan_branch_entry( branch: branch )
902
+ counters[ outcome ] += 1
903
+ end
904
+ counters
905
+ end
906
+
907
+ # Checks a single orphan branch for merged PR evidence and force-deletes if confirmed.
908
+ def prune_orphan_branch_entry( branch: )
909
+ tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
910
+ unless tip_sha_success
911
+ error_text = tip_sha_error.to_s.strip
912
+ error_text = "unable to read local branch tip sha" if error_text.empty?
913
+ puts_verbose "skip_orphan_branch: #{branch} reason=#{error_text}"
914
+ return :skipped
915
+ end
916
+ branch_tip_sha = tip_sha_text.to_s.strip
917
+ if branch_tip_sha.empty?
918
+ puts_verbose "skip_orphan_branch: #{branch} reason=unable to read local branch tip sha"
919
+ return :skipped
920
+ end
921
+
922
+ merged_pr, error = merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
923
+ if merged_pr.nil?
924
+ reason = error.to_s.strip
925
+ reason = "no merged PR evidence for branch tip into #{config.main_branch}" if reason.empty?
926
+ puts_verbose "skip_orphan_branch: #{branch} reason=#{reason}"
927
+ return :skipped
928
+ end
929
+
930
+ force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
931
+ if force_success
932
+ out.print force_stdout if verbose? && !force_stdout.empty?
933
+ puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
934
+ return :deleted
935
+ end
936
+
937
+ force_error_text = normalise_branch_delete_error( error_text: force_stderr )
938
+ puts_verbose "fail_delete_orphan_branch: #{branch} reason=#{force_error_text}"
939
+ :skipped
940
+ end
941
+
870
942
  # Safe delete can fail after squash merges because branch tip is no longer an ancestor.
871
943
  def non_merged_delete_error?( error_text: )
872
944
  error_text.to_s.downcase.include?( "not fully merged" )
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.19.0
4
+ version: 2.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang