carson 3.14.0 → 3.15.1

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: a6187204d01b1fc7f62657f451451a0124e039b265d83379ce8c23e9ba62a346
4
- data.tar.gz: d5b11033558afc8847386858bc62d9ba19816636dece60dfe4a5f1708528a50f
3
+ metadata.gz: b350c2bb611f2357a7ea9a422b8c1c836f551dc22a33c6ed70c9f0409425d208
4
+ data.tar.gz: 1333080091cb8e945df2c22e3349b4445124dc4ee856dd10de7b557a284c94df
5
5
  SHA512:
6
- metadata.gz: 005575df223f30f900794447f012d29bfa3c6a939433870da80dd54cdbcb43dd9099413357a049232ffdcd7d7c502996e3d4279a40f310fde576fe991c8659a0
7
- data.tar.gz: 9c4c5023488bce12162fbcb0c4746db515c72e2b28618ca35682a3076ec2b3c1c67513af7712cb4238dc76a2fd389f9abf75385baf6d63ea10970be960d949e9
6
+ metadata.gz: ba0030df2fde050f0fdd971970b4fe0c4053cad7d293076e6f11c6262feb012a7d7466a547a8812f4befbd6364bfed64e06af4308ab84a802611a3c15e16ee57
7
+ data.tar.gz: ca7d8bab1f94b3981d224d00aeedc388beba5c33f04f54494f5b9ef91e3f693b41440462a298e8c06146773b628a003d9b203ec2d0b3c06918502bfd961056f6
data/RELEASE.md CHANGED
@@ -5,15 +5,40 @@ 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
+ ## 3.15.1
9
+
10
+ ### What changed
11
+
12
+ - **Dead worktree reaping** — `housekeep` now reaps dead worktrees before pruning. Unblocks prune for branches held by stale worktrees.
13
+ - Two-layer dead check: fast content-absorbed test, then definitive merged-PR evidence via GitHub API. Covers simple merges and rebase/squash cases where main has evolved.
14
+ - Safe removal only — refuses dirty working trees (`git worktree remove` without `--force`).
15
+ - Deletes the local branch after removal (unless protected).
16
+
17
+ ## 3.15.0
18
+
19
+ ### What changed
20
+
21
+ - **`carson housekeep` command** — sync + prune for repositories. Carson knocks each gate humbly:
22
+ - `carson housekeep` — serve the repo you are standing in.
23
+ - `carson housekeep <repo>` — serve a named governed repo (resolved by basename, case-insensitive).
24
+ - `carson housekeep --all` — knock each governed repo's gate in turn.
25
+ - Supports `--json` for machine-readable output in all three modes.
26
+
27
+ ### UX improvement
28
+
29
+ - Fixed double-badge display when housekeep summarises prune results.
30
+ - JSON mode suppresses human-readable lines cleanly.
31
+
8
32
  ## 3.14.0
9
33
 
10
34
  ### What changed
11
35
 
12
36
  - **`carson repos` command** — lists all governed repositories from Carson's global config. Shows which repos Carson is serving at a glance. Supports `--json` for machine-readable output. Portfolio-level command — works from any directory.
37
+ - **Auto-register on onboard** — `carson onboard` now automatically registers the repository for portfolio governance. No more TTY-only Y/n prompt — onboard means govern.
13
38
 
14
39
  ### UX improvement
15
40
 
16
- - Bots and humans can now verify which repos Carson oversees with a single command instead of reading raw config JSON.
41
+ - Onboarding is now one step: `carson onboard` sets up hooks, templates, and portfolio registration in a single pass. Works identically in TTY and non-TTY (agent) sessions.
17
42
 
18
43
  ## 3.13.2
19
44
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.14.0
1
+ 3.15.1
data/lib/carson/cli.rb CHANGED
@@ -12,7 +12,7 @@ module Carson
12
12
  return Runtime::EXIT_OK
13
13
  end
14
14
 
15
- if %w[repos refresh:all prune:all].include?( command )
15
+ if %w[repos refresh:all prune:all housekeep:all housekeep:target].include?( command )
16
16
  verbose = parsed.fetch( :verbose, false )
17
17
  runtime = Runtime.new( repo_root: repo_root, tool_root: tool_root, out: out, err: err, verbose: verbose )
18
18
  return dispatch( parsed: parsed, runtime: runtime )
@@ -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 [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|remove <name>|repos [--json]|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
56
+ opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|remove <name>|housekeep [repo] [--json]|repos [--json]|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
57
57
  end
58
58
  end
59
59
 
@@ -88,6 +88,8 @@ module Carson
88
88
  parse_worktree_subcommand( argv: argv, parser: parser, err: err )
89
89
  when "repos"
90
90
  parse_repos_command( argv: argv, err: err )
91
+ when "housekeep"
92
+ parse_housekeep_command( argv: argv, err: err )
91
93
  when "review"
92
94
  parse_named_subcommand( command: command, usage: "gate|sweep", argv: argv, parser: parser, err: err )
93
95
  when "audit"
@@ -310,6 +312,28 @@ module Carson
310
312
  { command: "repos", json: json_flag }
311
313
  end
312
314
 
315
+ def self.parse_housekeep_command( argv:, err: )
316
+ all_flag = argv.delete( "--all" ) ? true : false
317
+ json_flag = argv.delete( "--json" ) ? true : false
318
+
319
+ if all_flag && !argv.empty?
320
+ err.puts "#{BADGE} --all and repo target are mutually exclusive. Use: carson housekeep --all OR carson housekeep [repo]"
321
+ return { command: :invalid }
322
+ end
323
+
324
+ return { command: "housekeep:all", json: json_flag } if all_flag
325
+
326
+ if argv.length > 1
327
+ err.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
328
+ return { command: :invalid }
329
+ end
330
+
331
+ target = argv.shift
332
+ return { command: "housekeep:target", target: target, json: json_flag } if target
333
+
334
+ { command: "housekeep", json: json_flag }
335
+ end
336
+
313
337
  def self.parse_govern_subcommand( argv:, err: )
314
338
  options = {
315
339
  dry_run: false,
@@ -389,6 +413,12 @@ module Carson
389
413
  runtime.review_sweep!
390
414
  when "repos"
391
415
  runtime.repos!( json_output: parsed.fetch( :json, false ) )
416
+ when "housekeep"
417
+ runtime.housekeep!( json_output: parsed.fetch( :json, false ) )
418
+ when "housekeep:target"
419
+ runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) )
420
+ when "housekeep:all"
421
+ runtime.housekeep_all!( json_output: parsed.fetch( :json, false ) )
392
422
  when "govern"
393
423
  runtime.govern!(
394
424
  dry_run: parsed.fetch( :dry_run, false ),
@@ -0,0 +1,168 @@
1
+ # Housekeeping — sync, reap dead worktrees, and prune for a repository.
2
+ # carson housekeep <repo> — serve one repo by name or path.
3
+ # carson housekeep — serve the repo you are standing in.
4
+ # carson housekeep --all — serve all governed repos.
5
+ require "json"
6
+ require "open3"
7
+ require "stringio"
8
+
9
+ module Carson
10
+ class Runtime
11
+ module Housekeep
12
+ # Serves the current repo: sync + prune.
13
+ def housekeep!( json_output: false )
14
+ housekeep_one( repo_path: repo_root, json_output: json_output )
15
+ end
16
+
17
+ # Resolves a target name to a governed repo, then serves it.
18
+ def housekeep_target!( target:, json_output: false )
19
+ repo_path = resolve_governed_repo( target: target )
20
+ unless repo_path
21
+ result = { command: "housekeep", status: "error", error: "Not a governed repository: #{target}", recovery: "Run carson repos to see governed repositories." }
22
+ return housekeep_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
23
+ end
24
+
25
+ housekeep_one( repo_path: repo_path, json_output: json_output )
26
+ end
27
+
28
+ # Knocks each governed repo's gate in turn.
29
+ def housekeep_all!( json_output: false )
30
+ repos = config.govern_repos
31
+ if repos.empty?
32
+ result = { command: "housekeep", status: "error", error: "No governed repositories configured.", recovery: "Run carson onboard in each repo to register." }
33
+ return housekeep_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
34
+ end
35
+
36
+ results = []
37
+ repos.each { |repo_path| results << housekeep_one_entry( repo_path: repo_path, silent: json_output ) }
38
+
39
+ succeeded = results.count { |r| r[ :status ] == "ok" }
40
+ failed = results.count { |r| r[ :status ] != "ok" }
41
+ result = { command: "housekeep", status: failed.zero? ? "ok" : "partial", repos: results, succeeded: succeeded, failed: failed }
42
+ housekeep_finish( result: result, exit_code: failed.zero? ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: results, succeeded: succeeded, failed: failed )
43
+ end
44
+
45
+ # Removes dead worktrees — those with a merged PR and a clean working tree.
46
+ # Unblocks prune for the branches they hold.
47
+ # Two-layer dead check:
48
+ # 1. Fast: branch content is fully absorbed into main (covers simple cases).
49
+ # 2. Definitive: a merged PR exists for this branch (covers rebase/squash where
50
+ # main has since evolved the same files).
51
+ def reap_dead_worktrees!
52
+ return unless gh_available?
53
+
54
+ main_root = main_worktree_root
55
+ worktrees = worktree_list
56
+
57
+ worktrees.each do |wt|
58
+ path = wt.fetch( :path )
59
+ branch = wt.fetch( :branch, nil )
60
+ next if path == main_root
61
+ next unless branch
62
+ next if cwd_inside_worktree?( worktree_path: path )
63
+
64
+ # Dead check: absorbed into main, or merged PR evidence.
65
+ dead = branch_absorbed_into_main?( branch: branch )
66
+ unless dead
67
+ tip_sha = git_capture!( "rev-parse", "--verify", branch ).strip rescue nil
68
+ if tip_sha
69
+ merged_pr, = merged_pr_for_branch( branch: branch, branch_tip_sha: tip_sha )
70
+ dead = !merged_pr.nil?
71
+ end
72
+ end
73
+ next unless dead
74
+
75
+ # Remove the worktree (no --force: refuses if dirty working tree).
76
+ _, _, rm_success, = git_run( "worktree", "remove", path )
77
+ next unless rm_success
78
+
79
+ puts_verbose "reaped dead worktree: #{File.basename( path )} (branch: #{branch})"
80
+
81
+ # Delete the local branch now that no worktree holds it.
82
+ if !config.protected_branches.include?( branch )
83
+ git_run( "branch", "-D", branch )
84
+ puts_verbose "deleted branch: #{branch}"
85
+ end
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ # Runs sync + prune on one repo and returns the exit code directly.
92
+ def housekeep_one( repo_path:, json_output: false )
93
+ entry = housekeep_one_entry( repo_path: repo_path, silent: json_output )
94
+ ok = entry[ :status ] == "ok"
95
+ result = { command: "housekeep", status: ok ? "ok" : "error", repos: [ entry ], succeeded: ok ? 1 : 0, failed: ok ? 0 : 1 }
96
+ housekeep_finish( result: result, exit_code: ok ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: [ entry ], succeeded: result[ :succeeded ], failed: result[ :failed ] )
97
+ end
98
+
99
+ # Runs sync + prune on a single repository. Returns a result hash.
100
+ def housekeep_one_entry( repo_path:, silent: false )
101
+ repo_name = File.basename( repo_path )
102
+ unless Dir.exist?( repo_path )
103
+ puts_line "#{repo_name}: SKIP (path not found)" unless silent
104
+ return { name: repo_name, path: repo_path, status: "error", error: "path not found" }
105
+ end
106
+
107
+ buf = verbose? ? out : StringIO.new
108
+ err_buf = verbose? ? err : StringIO.new
109
+ rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
110
+
111
+ sync_status = rt.sync!
112
+ if sync_status == EXIT_OK
113
+ rt.reap_dead_worktrees!
114
+ prune_status = rt.prune!
115
+ end
116
+
117
+ ok = sync_status == EXIT_OK && prune_status == EXIT_OK
118
+ unless verbose? || silent
119
+ summary = strip_badge( buf.string.lines.last.to_s.strip )
120
+ puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
121
+ end
122
+
123
+ { name: repo_name, path: repo_path, status: ok ? "ok" : "error" }
124
+ rescue StandardError => e
125
+ puts_line "#{repo_name}: FAIL (#{e.message})" unless silent
126
+ { name: repo_name, path: repo_path, status: "error", error: e.message }
127
+ end
128
+
129
+ # Strips the Carson badge prefix from a message to avoid double-badging.
130
+ def strip_badge( text )
131
+ text.sub( /\A#{Regexp.escape( BADGE )}\s*/, "" )
132
+ end
133
+
134
+ # Resolves a user-supplied target to a governed repository path.
135
+ # Accepts: exact path, expandable path, or basename match (case-insensitive).
136
+ def resolve_governed_repo( target: )
137
+ repos = config.govern_repos
138
+ expanded = File.expand_path( target )
139
+ return expanded if repos.include?( expanded )
140
+
141
+ downcased = File.basename( target ).downcase
142
+ repos.find { |r| File.basename( r ).downcase == downcased }
143
+ end
144
+
145
+ # Unified output — JSON or human-readable.
146
+ def housekeep_finish( result:, exit_code:, json_output:, results: nil, succeeded: nil, failed: nil )
147
+ result[ :exit_code ] = exit_code
148
+
149
+ if json_output
150
+ out.puts JSON.pretty_generate( result )
151
+ else
152
+ if results && ( succeeded || failed )
153
+ total = ( succeeded || 0 ) + ( failed || 0 )
154
+ puts_line ""
155
+ puts_line "Housekeep complete: #{succeeded} cleaned, #{failed} failed (#{total} repo#{plural_suffix( count: total )})."
156
+ elsif result[ :error ]
157
+ puts_line result[ :error ]
158
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
159
+ end
160
+ end
161
+
162
+ exit_code
163
+ end
164
+ end
165
+
166
+ include Housekeep
167
+ end
168
+ end
@@ -217,6 +217,7 @@ end
217
217
 
218
218
  require_relative "runtime/local"
219
219
  require_relative "runtime/audit"
220
+ require_relative "runtime/housekeep"
220
221
  require_relative "runtime/repos"
221
222
  require_relative "runtime/review"
222
223
  require_relative "runtime/govern"
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: 3.14.0
4
+ version: 3.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -54,6 +54,7 @@ files:
54
54
  - lib/carson/runtime/audit.rb
55
55
  - lib/carson/runtime/deliver.rb
56
56
  - lib/carson/runtime/govern.rb
57
+ - lib/carson/runtime/housekeep.rb
57
58
  - lib/carson/runtime/local.rb
58
59
  - lib/carson/runtime/local/hooks.rb
59
60
  - lib/carson/runtime/local/onboard.rb