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 +4 -4
- data/RELEASE.md +26 -1
- data/VERSION +1 -1
- data/lib/carson/cli.rb +32 -2
- data/lib/carson/runtime/housekeep.rb +168 -0
- data/lib/carson/runtime.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b350c2bb611f2357a7ea9a422b8c1c836f551dc22a33c6ed70c9f0409425d208
|
|
4
|
+
data.tar.gz: 1333080091cb8e945df2c22e3349b4445124dc4ee856dd10de7b557a284c94df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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.
|
|
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
|
data/lib/carson/runtime.rb
CHANGED
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.
|
|
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
|