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 +4 -4
- data/RELEASE.md +39 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +26 -2
- data/lib/carson/runtime/local/prune.rb +106 -1
- data/lib/carson/runtime/setup.rb +6 -2
- 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: 7117079168b4f8472a4324f954bc087122e0ded0d253252b73702c41d3996283
|
|
4
|
+
data.tar.gz: 21484878bcafe5799dfd87a18c223b5982d6b2ff1ab988c78f929de9cd642bdd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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)
|
|
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?
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -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?
|