carson 2.26.0 → 2.28.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: b9a6bf9aa88547c46e4d2138f5da872e38684d10056d948ce7d97124173b37ac
4
- data.tar.gz: 285255f7661b92a7943d92b0b421858064980b9cfdfa39c1eb643c40590948e6
3
+ metadata.gz: 7117079168b4f8472a4324f954bc087122e0ded0d253252b73702c41d3996283
4
+ data.tar.gz: 21484878bcafe5799dfd87a18c223b5982d6b2ff1ab988c78f929de9cd642bdd
5
5
  SHA512:
6
- metadata.gz: 10b6caadf5c171c80052bab4f02c124b52d4f5808f3381ceca0735755e411bf3704654c7f130fe386557f0c31034036630d4df8ce1da571f00fce6c2e91bf0e5
7
- data.tar.gz: c7f53dfd624afef9d54dd6df6b2eb2f6e8ccefe59f5d092692335c75a2a5854ab8d2d8fc5c178815816746a74196691fff8105f5b3cc5f594ca6017db2938c92
6
+ metadata.gz: bb4b347d7ace09997a93b40fc7ec179baf4eb8aee75a620e3511d4ac7b9c46545380f7157ac442807e09cc1a679e8709a62df1a237a41618501688162f99c511
7
+ data.tar.gz: 91aaf60433a82548170eb66eef14ce90c0eb730a3a56e06f7c154449d2355ca50f9a1e282d1b26e372c34b1df2946cb804587854875c14d1ffec3da5d6658bff
data/RELEASE.md CHANGED
@@ -5,6 +5,45 @@ 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.28.0 — Non-Interactive CLI Flags for Setup
9
+
10
+ ### What changed
11
+
12
+ - **`carson setup` now accepts optional CLI flags** to configure settings without interactive prompts:
13
+ - `--remote NAME` sets `git.remote`
14
+ - `--main-branch NAME` sets `git.main_branch`
15
+ - `--workflow STYLE` sets `workflow.style` (branch or trunk)
16
+ - `--merge METHOD` sets `govern.merge.method` (squash, rebase, or merge)
17
+ - `--canonical PATH` sets `template.canonical`
18
+ - When any flag is present, interactive prompts are skipped and only the specified values are written.
19
+ - When no flags are present, existing behaviour is preserved (interactive in TTY, silent detection in non-TTY).
20
+
21
+ ### Why
22
+
23
+ Running `carson setup` in non-TTY environments (CI pipelines, agent scripts) previously required manually editing `config.json`. CLI flags allow fully automated configuration without file manipulation.
24
+
25
+ ### Migration
26
+
27
+ No action required. Existing behaviour is unchanged when no flags are provided.
28
+
29
+ ## 2.27.0 — Absorbed Branch Pruning
30
+
31
+ ### What changed
32
+
33
+ - **`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.
34
+ - 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.
35
+ - Fast path: branches that are strict ancestors of main (fully merged via fast-forward) are also detected.
36
+ - Safety: absorbed branches are only deleted when no open PR exists. If `gh` is unavailable, absorbed detection is skipped entirely.
37
+ - Both local and remote branches are cleaned up.
38
+
39
+ ### Why
40
+
41
+ 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.
42
+
43
+ ### Migration
44
+
45
+ No action required. `carson prune` gains the new detection automatically.
46
+
8
47
  ## 2.26.0 — Baseline Check No Longer Blocks Commits
9
48
 
10
49
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.26.0
1
+ 2.28.0
data/lib/carson/cli.rb CHANGED
@@ -53,7 +53,7 @@ module Carson
53
53
 
54
54
  def self.build_parser
55
55
  OptionParser.new do |opts|
56
- opts.banner = "Usage: carson [setup|audit|sync|prune|onboard [repo_path]|refresh [--all|repo_path]|offboard [repo_path]|template check|template apply|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
56
+ opts.banner = "Usage: carson [setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]|audit|sync|prune|onboard [repo_path]|refresh [--all|repo_path]|offboard [repo_path]|template check|template apply|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
57
57
  end
58
58
  end
59
59
 
@@ -74,6 +74,8 @@ module Carson
74
74
  when "version"
75
75
  parser.parse!( argv )
76
76
  { command: "version" }
77
+ when "setup"
78
+ parse_setup_command( argv: argv, parser: parser, err: err )
77
79
  when "onboard", "offboard"
78
80
  parse_repo_path_command( command: command, argv: argv, parser: parser, err: err )
79
81
  when "refresh"
@@ -90,6 +92,28 @@ module Carson
90
92
  end
91
93
  end
92
94
 
95
+ def self.parse_setup_command( argv:, parser:, err: )
96
+ options = {}
97
+ setup_parser = OptionParser.new do |opts|
98
+ opts.banner = "Usage: carson setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]"
99
+ opts.on( "--remote NAME", "Git remote name" ) { |v| options[ "git.remote" ] = v }
100
+ opts.on( "--main-branch NAME", "Main branch name" ) { |v| options[ "git.main_branch" ] = v }
101
+ opts.on( "--workflow STYLE", "Workflow style (branch or trunk)" ) { |v| options[ "workflow.style" ] = v }
102
+ opts.on( "--merge METHOD", "Merge method (squash, rebase, or merge)" ) { |v| options[ "govern.merge.method" ] = v }
103
+ opts.on( "--canonical PATH", "Canonical template directory path" ) { |v| options[ "template.canonical" ] = v }
104
+ end
105
+ setup_parser.parse!( argv )
106
+ unless argv.empty?
107
+ err.puts "#{BADGE} Unexpected arguments for setup: #{argv.join( ' ' )}"
108
+ err.puts setup_parser
109
+ return { command: :invalid }
110
+ end
111
+ { command: "setup", cli_choices: options }
112
+ rescue OptionParser::ParseError => e
113
+ err.puts "#{BADGE} #{e.message}"
114
+ { command: :invalid }
115
+ end
116
+
93
117
  def self.parse_repo_path_command( command:, argv:, parser:, err: )
94
118
  parser.parse!( argv )
95
119
  if argv.length > 1
@@ -209,7 +233,7 @@ module Carson
209
233
 
210
234
  case command
211
235
  when "setup"
212
- runtime.setup!
236
+ runtime.setup!( cli_choices: parsed.fetch( :cli_choices, {} ) )
213
237
  when "audit"
214
238
  runtime.audit!
215
239
  when "sync"
@@ -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?
@@ -6,13 +6,17 @@ module Carson
6
6
  module Setup
7
7
  WELL_KNOWN_REMOTES = %w[origin github upstream].freeze
8
8
 
9
- def setup!
9
+ def setup!( cli_choices: {} )
10
10
  puts_verbose ""
11
11
  puts_verbose "[Setup]"
12
12
 
13
13
  unless inside_git_work_tree?
14
14
  puts_line "WARN: not a git repository. Skipping remote and branch detection."
15
- return write_setup_config( choices: {} )
15
+ return write_setup_config( choices: cli_choices )
16
+ end
17
+
18
+ unless cli_choices.empty?
19
+ return write_setup_config( choices: cli_choices )
16
20
  end
17
21
 
18
22
  if self.in.respond_to?( :tty? ) && self.in.tty?
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.26.0
4
+ version: 2.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang