carson 3.22.0 → 3.22.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/API.md +4 -9
- data/MANUAL.md +45 -28
- data/README.md +45 -50
- data/RELEASE.md +9 -1
- data/SKILL.md +1 -1
- data/VERSION +1 -1
- data/carson.gemspec +2 -4
- data/hooks/command-guard +1 -1
- data/hooks/pre-push +17 -20
- data/lib/carson/cli.rb +32 -16
- data/lib/carson/config.rb +46 -11
- data/lib/carson/runtime/audit.rb +37 -11
- data/lib/carson/runtime/deliver.rb +113 -42
- data/lib/carson/runtime/govern.rb +29 -17
- data/lib/carson/runtime/housekeep.rb +231 -25
- data/lib/carson/runtime/local/onboard.rb +24 -24
- data/lib/carson/runtime/local/prune.rb +116 -31
- data/lib/carson/runtime/local/sync.rb +29 -7
- data/lib/carson/runtime/local/template.rb +26 -8
- data/lib/carson/runtime/local/worktree.rb +37 -442
- data/lib/carson/runtime/review/gate_support.rb +131 -1
- data/lib/carson/runtime/review.rb +21 -77
- data/lib/carson/runtime/setup.rb +15 -6
- data/lib/carson/runtime/status.rb +35 -12
- data/lib/carson/runtime.rb +15 -3
- data/lib/carson/worktree.rb +497 -0
- data/lib/carson.rb +1 -0
- metadata +11 -16
- data/.github/copilot-instructions.md +0 -1
- data/.github/pull_request_template.md +0 -12
- data/templates/.github/AGENTS.md +0 -1
- data/templates/.github/CLAUDE.md +0 -1
- data/templates/.github/carson.md +0 -47
- data/templates/.github/copilot-instructions.md +0 -1
- data/templates/.github/pull_request_template.md +0 -12
|
@@ -56,7 +56,7 @@ module Carson
|
|
|
56
56
|
|
|
57
57
|
EXIT_OK
|
|
58
58
|
rescue StandardError => exception
|
|
59
|
-
puts_line "
|
|
59
|
+
puts_line "Govern did not complete: #{exception.message}"
|
|
60
60
|
EXIT_ERROR
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -70,7 +70,7 @@ module Carson
|
|
|
70
70
|
begin
|
|
71
71
|
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
72
72
|
rescue StandardError => exception
|
|
73
|
-
puts_line "
|
|
73
|
+
puts_line "Cycle #{cycle_count} did not complete: #{exception.message}"
|
|
74
74
|
end
|
|
75
75
|
puts_line "sleeping #{loop_seconds}s until next cycle…"
|
|
76
76
|
sleep loop_seconds
|
|
@@ -88,7 +88,7 @@ module Carson
|
|
|
88
88
|
config.govern_repos.map do |path|
|
|
89
89
|
expanded = File.expand_path( path )
|
|
90
90
|
unless Dir.exist?( expanded )
|
|
91
|
-
puts_line "
|
|
91
|
+
puts_line "Skipping #{expanded} — path not found"
|
|
92
92
|
next nil
|
|
93
93
|
end
|
|
94
94
|
expanded
|
|
@@ -107,14 +107,14 @@ module Carson
|
|
|
107
107
|
|
|
108
108
|
unless Dir.exist?( repo_path )
|
|
109
109
|
repo_report[ :error ] = "path does not exist"
|
|
110
|
-
puts_line "
|
|
110
|
+
puts_line "#{repo_path}: path not found, skipping"
|
|
111
111
|
return repo_report
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
prs = list_open_prs( repo_path: repo_path )
|
|
115
115
|
if prs.nil?
|
|
116
116
|
repo_report[ :error ] = "failed to list open PRs"
|
|
117
|
-
puts_line "
|
|
117
|
+
puts_line "#{File.basename(repo_path)}: unable to list open PRs"
|
|
118
118
|
return repo_report
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -196,8 +196,12 @@ module Carson
|
|
|
196
196
|
if review_decision == "CHANGES_REQUESTED"
|
|
197
197
|
return [ TRIAGE_REVIEW_BLOCKED, "changes requested by reviewer" ]
|
|
198
198
|
end
|
|
199
|
+
if review_decision == "REVIEW_REQUIRED"
|
|
200
|
+
return [ TRIAGE_REVIEW_BLOCKED, "review required" ]
|
|
201
|
+
end
|
|
199
202
|
|
|
200
203
|
review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
|
|
204
|
+
return [ TRIAGE_NEEDS_ATTENTION, review_detail ] if review_status == :error
|
|
201
205
|
return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
|
|
202
206
|
|
|
203
207
|
[ TRIAGE_READY, "all gates pass" ]
|
|
@@ -231,17 +235,25 @@ module Carson
|
|
|
231
235
|
|
|
232
236
|
# Checks review gate status. Returns [:pass/:fail, detail].
|
|
233
237
|
def check_review_gate_status( pr:, repo_path: )
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
238
|
+
repo_runtime = scoped_runtime( repo_path: repo_path )
|
|
239
|
+
owner, repo = repo_runtime.send( :repository_coordinates )
|
|
240
|
+
report = repo_runtime.send(
|
|
241
|
+
:review_gate_report_for_pr,
|
|
242
|
+
owner: owner,
|
|
243
|
+
repo: repo,
|
|
244
|
+
pr_number: pr.fetch( "number" ),
|
|
245
|
+
branch_name: pr.fetch( "headRefName" ).to_s,
|
|
246
|
+
pr_summary: {
|
|
247
|
+
number: pr.fetch( "number" ),
|
|
248
|
+
title: pr.fetch( "title" ).to_s,
|
|
249
|
+
url: pr.fetch( "url" ).to_s,
|
|
250
|
+
state: "OPEN"
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
result = repo_runtime.send( :review_gate_result, report: report )
|
|
254
|
+
[ result.fetch( :status ), result.fetch( :detail ) ]
|
|
255
|
+
rescue StandardError => exception
|
|
256
|
+
[ :error, "review gate check failed: #{exception.message}" ]
|
|
245
257
|
end
|
|
246
258
|
|
|
247
259
|
# Maps classification to action.
|
|
@@ -296,7 +308,7 @@ module Carson
|
|
|
296
308
|
housekeep_repo!( repo_path: repo_path )
|
|
297
309
|
else
|
|
298
310
|
error_text = stderr_text.to_s.strip
|
|
299
|
-
puts_line " merge
|
|
311
|
+
puts_line " merge did not succeed: #{error_text}"
|
|
300
312
|
end
|
|
301
313
|
end
|
|
302
314
|
|
|
@@ -10,29 +10,52 @@ module Carson
|
|
|
10
10
|
class Runtime
|
|
11
11
|
module Housekeep
|
|
12
12
|
# Serves the current repo: sync + prune.
|
|
13
|
-
def housekeep!( json_output: false )
|
|
13
|
+
def housekeep!( json_output: false, dry_run: false )
|
|
14
|
+
return housekeep_one_dry_run if dry_run
|
|
15
|
+
|
|
14
16
|
housekeep_one( repo_path: repo_root, json_output: json_output )
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
# Resolves a target name to a governed repo, then serves it.
|
|
18
|
-
def housekeep_target!( target:, json_output: false )
|
|
20
|
+
def housekeep_target!( target:, json_output: false, dry_run: false )
|
|
19
21
|
repo_path = resolve_governed_repo( target: target )
|
|
20
22
|
unless repo_path
|
|
21
23
|
result = { command: "housekeep", status: "error", error: "Not a governed repository: #{target}", recovery: "Run carson repos to see governed repositories." }
|
|
22
24
|
return housekeep_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
if dry_run
|
|
28
|
+
scoped = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error, verbose: verbose? )
|
|
29
|
+
return scoped.housekeep_one_dry_run
|
|
30
|
+
end
|
|
31
|
+
|
|
25
32
|
housekeep_one( repo_path: repo_path, json_output: json_output )
|
|
26
33
|
end
|
|
27
34
|
|
|
28
35
|
# Knocks each governed repo's gate in turn.
|
|
29
|
-
def housekeep_all!( json_output: false )
|
|
36
|
+
def housekeep_all!( json_output: false, dry_run: false )
|
|
30
37
|
repos = config.govern_repos
|
|
31
38
|
if repos.empty?
|
|
32
39
|
result = { command: "housekeep", status: "error", error: "No governed repositories configured.", recovery: "Run carson onboard in each repo to register." }
|
|
33
40
|
return housekeep_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
34
41
|
end
|
|
35
42
|
|
|
43
|
+
if dry_run
|
|
44
|
+
repos.each_with_index do |repo_path, idx|
|
|
45
|
+
puts_line "" if idx > 0
|
|
46
|
+
unless Dir.exist?( repo_path )
|
|
47
|
+
puts_line "#{File.basename( repo_path )}: SKIP (path not found)"
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
scoped = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error, verbose: verbose? )
|
|
51
|
+
scoped.housekeep_one_dry_run
|
|
52
|
+
end
|
|
53
|
+
total = repos.size
|
|
54
|
+
puts_line ""
|
|
55
|
+
puts_line "#{total} repo#{plural_suffix( count: total )} surveyed. Run without --dry-run to apply."
|
|
56
|
+
return EXIT_OK
|
|
57
|
+
end
|
|
58
|
+
|
|
36
59
|
results = []
|
|
37
60
|
repos.each do |repo_path|
|
|
38
61
|
entry = housekeep_one_entry( repo_path: repo_path, silent: json_output )
|
|
@@ -50,55 +73,166 @@ module Carson
|
|
|
50
73
|
housekeep_finish( result: result, exit_code: failed.zero? ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: results, succeeded: succeeded, failed: failed )
|
|
51
74
|
end
|
|
52
75
|
|
|
53
|
-
#
|
|
76
|
+
# Prints a dry-run plan for this repo without making any changes.
|
|
77
|
+
# Calls reap_dead_worktrees_plan and prune_plan on self (already scoped to the repo).
|
|
78
|
+
def housekeep_one_dry_run
|
|
79
|
+
repo_name = File.basename( repo_root )
|
|
80
|
+
worktree_plan = reap_dead_worktrees_plan
|
|
81
|
+
branch_plan = prune_plan( dry_run: true )
|
|
82
|
+
print_housekeep_dry_run( repo_name: repo_name, worktree_plan: worktree_plan, branch_plan: branch_plan )
|
|
83
|
+
EXIT_OK
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns a plan array describing what reap_dead_worktrees! would do for each
|
|
87
|
+
# non-main worktree, without executing any mutations.
|
|
88
|
+
# Each item: { name:, branch:, action: :reap|:skip, reason: }
|
|
89
|
+
def reap_dead_worktrees_plan
|
|
90
|
+
main_root = main_worktree_root
|
|
91
|
+
items = []
|
|
92
|
+
|
|
93
|
+
agent_prefixes = Worktree::AGENT_DIRS.filter_map do |dir|
|
|
94
|
+
full = File.join( main_root, dir, "worktrees" )
|
|
95
|
+
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
worktree_list.each do |worktree|
|
|
99
|
+
next if worktree.path == main_root
|
|
100
|
+
next unless worktree.branch
|
|
101
|
+
|
|
102
|
+
item = { name: File.basename( worktree.path ), branch: worktree.branch }
|
|
103
|
+
|
|
104
|
+
if worktree.holds_cwd?
|
|
105
|
+
items << item.merge( action: :skip, reason: "held by current shell" )
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if worktree.held_by_other_process?
|
|
110
|
+
items << item.merge( action: :skip, reason: "held by another process" )
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Missing directory — would be reaped by worktree prune + branch delete.
|
|
115
|
+
unless Dir.exist?( worktree.path )
|
|
116
|
+
items << item.merge( action: :reap, reason: "directory missing (destroyed externally)" )
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Layer 1: agent-owned + content absorbed into main (no gh needed).
|
|
121
|
+
if agent_prefixes.any? { |prefix| worktree.path.start_with?( prefix ) } &&
|
|
122
|
+
branch_absorbed_into_main?( branch: worktree.branch )
|
|
123
|
+
items << item.merge( action: :reap, reason: "content absorbed into main" )
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Layers 2 + 3: PR evidence — requires gh CLI.
|
|
128
|
+
unless gh_available?
|
|
129
|
+
items << item.merge( action: :skip, reason: "gh CLI not available for PR check" )
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
tip_sha = begin
|
|
134
|
+
git_capture!( "rev-parse", "--verify", worktree.branch ).strip
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
unless tip_sha
|
|
140
|
+
items << item.merge( action: :skip, reason: "cannot read branch tip SHA" )
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
|
|
145
|
+
if merged_pr
|
|
146
|
+
items << item.merge( action: :reap, reason: "merged #{pr_short_ref( merged_pr[ :url ] )}" )
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if branch_has_open_pr?( branch: worktree.branch )
|
|
151
|
+
items << item.merge( action: :skip, reason: "open PR exists" )
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
|
|
156
|
+
if abandoned_pr
|
|
157
|
+
items << item.merge( action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr[ :url ] )}" )
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
items << item.merge( action: :skip, reason: "no evidence to reap" )
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
items
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Removes dead worktrees — those whose content is on main, with merged PR evidence,
|
|
168
|
+
# or with closed-unmerged PR evidence and no open PR.
|
|
54
169
|
# Unblocks prune for the branches they hold.
|
|
55
|
-
#
|
|
170
|
+
# Three-layer dead check:
|
|
56
171
|
# 1. Content-absorbed: delegates to sweep_stale_worktrees! (shared, no gh needed).
|
|
57
172
|
# 2. Merged PR evidence: covers rebase/squash where main has since evolved
|
|
58
173
|
# the same files (requires gh).
|
|
174
|
+
# 3. Abandoned PR evidence: closed-but-unmerged PR on the exact branch tip,
|
|
175
|
+
# but only when no open PR still exists for the branch.
|
|
59
176
|
def reap_dead_worktrees!
|
|
60
177
|
# Layer 1: sweep agent-owned worktrees whose content is on main.
|
|
61
178
|
sweep_stale_worktrees!
|
|
62
179
|
|
|
63
|
-
#
|
|
180
|
+
# Layers 2 and 3: PR evidence for remaining worktrees.
|
|
64
181
|
return unless gh_available?
|
|
65
182
|
|
|
66
183
|
main_root = main_worktree_root
|
|
67
184
|
worktree_list.each do |worktree|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
next if
|
|
71
|
-
next
|
|
72
|
-
next if cwd_inside_worktree?( worktree_path: path )
|
|
185
|
+
next if worktree.path == main_root
|
|
186
|
+
next unless worktree.branch
|
|
187
|
+
next if worktree.holds_cwd?
|
|
188
|
+
next if worktree.held_by_other_process?
|
|
73
189
|
|
|
74
190
|
# Missing directory: worktree was destroyed externally.
|
|
75
191
|
# Prune the stale entry and delete the branch immediately.
|
|
76
|
-
unless Dir.exist?( path )
|
|
192
|
+
unless Dir.exist?( worktree.path )
|
|
77
193
|
git_run( "worktree", "prune" )
|
|
78
|
-
puts_verbose "reaped stale worktree entry: #{File.basename( path )} (branch: #{branch})"
|
|
79
|
-
if !config.protected_branches.include?( branch )
|
|
80
|
-
git_run( "branch", "-D", branch )
|
|
81
|
-
puts_verbose "deleted branch: #{branch}"
|
|
194
|
+
puts_verbose "reaped stale worktree entry: #{File.basename( worktree.path )} (branch: #{worktree.branch})"
|
|
195
|
+
if !config.protected_branches.include?( worktree.branch )
|
|
196
|
+
git_run( "branch", "-D", worktree.branch )
|
|
197
|
+
puts_verbose "deleted branch: #{worktree.branch}"
|
|
82
198
|
end
|
|
83
199
|
next
|
|
84
200
|
end
|
|
85
201
|
|
|
86
|
-
tip_sha = git_capture!( "rev-parse", "--verify", branch ).strip rescue nil
|
|
202
|
+
tip_sha = git_capture!( "rev-parse", "--verify", worktree.branch ).strip rescue nil
|
|
87
203
|
next unless tip_sha
|
|
88
204
|
|
|
89
|
-
merged_pr, = merged_pr_for_branch( branch: branch, branch_tip_sha: tip_sha )
|
|
90
|
-
|
|
205
|
+
merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
|
|
206
|
+
if !merged_pr.nil?
|
|
207
|
+
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
208
|
+
_, _, rm_success, = git_run( "worktree", "remove", worktree.path )
|
|
209
|
+
next unless rm_success
|
|
210
|
+
|
|
211
|
+
puts_verbose "reaped dead worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch})"
|
|
212
|
+
|
|
213
|
+
# Delete the local branch now that no worktree holds it.
|
|
214
|
+
if !config.protected_branches.include?( worktree.branch )
|
|
215
|
+
git_run( "branch", "-D", worktree.branch )
|
|
216
|
+
puts_verbose "deleted branch: #{worktree.branch}"
|
|
217
|
+
end
|
|
218
|
+
next
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
next if branch_has_open_pr?( branch: worktree.branch )
|
|
222
|
+
|
|
223
|
+
abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
|
|
224
|
+
next if abandoned_pr.nil?
|
|
91
225
|
|
|
92
226
|
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
93
|
-
_, _, rm_success, = git_run( "worktree", "remove", path )
|
|
227
|
+
_, _, rm_success, = git_run( "worktree", "remove", worktree.path )
|
|
94
228
|
next unless rm_success
|
|
95
229
|
|
|
96
|
-
puts_verbose "reaped
|
|
230
|
+
puts_verbose "reaped abandoned worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch}, closed PR: #{abandoned_pr.fetch( :url )})"
|
|
97
231
|
|
|
98
232
|
# Delete the local branch now that no worktree holds it.
|
|
99
|
-
if !config.protected_branches.include?( branch )
|
|
100
|
-
git_run( "branch", "-D", branch )
|
|
101
|
-
puts_verbose "deleted branch: #{branch}"
|
|
233
|
+
if !config.protected_branches.include?( worktree.branch )
|
|
234
|
+
git_run( "branch", "-D", worktree.branch )
|
|
235
|
+
puts_verbose "deleted branch: #{worktree.branch}"
|
|
102
236
|
end
|
|
103
237
|
end
|
|
104
238
|
end
|
|
@@ -139,7 +273,7 @@ module Carson
|
|
|
139
273
|
|
|
140
274
|
{ name: repo_name, path: repo_path, status: ok ? "ok" : "error" }
|
|
141
275
|
rescue StandardError => exception
|
|
142
|
-
puts_line "#{repo_name}:
|
|
276
|
+
puts_line "#{repo_name}: did not complete (#{exception.message})" unless silent
|
|
143
277
|
{ name: repo_name, path: repo_path, status: "error", error: exception.message }
|
|
144
278
|
end
|
|
145
279
|
|
|
@@ -178,6 +312,78 @@ module Carson
|
|
|
178
312
|
|
|
179
313
|
exit_code
|
|
180
314
|
end
|
|
315
|
+
|
|
316
|
+
# Formats and prints the dry-run plan for one repo.
|
|
317
|
+
def print_housekeep_dry_run( repo_name:, worktree_plan:, branch_plan: )
|
|
318
|
+
stale = branch_plan.fetch( :stale, [] )
|
|
319
|
+
orphan = branch_plan.fetch( :orphan, [] )
|
|
320
|
+
absorbed = branch_plan.fetch( :absorbed, [] )
|
|
321
|
+
|
|
322
|
+
all_items = worktree_plan + stale + orphan + absorbed
|
|
323
|
+
would_apply = all_items.count { |i| i[ :action ] == :reap || i[ :action ] == :delete }
|
|
324
|
+
would_skip = all_items.count { |i| i[ :action ] == :skip }
|
|
325
|
+
|
|
326
|
+
# Column widths for aligned output.
|
|
327
|
+
name_width = [ ( worktree_plan + stale + orphan + absorbed ).map { |i| i[ :name ].to_s.length + i[ :branch ].to_s.length }.max || 0, 28 ].min + 2
|
|
328
|
+
reason_width = 34
|
|
329
|
+
|
|
330
|
+
puts_line "Dry run — #{repo_name}"
|
|
331
|
+
note = gh_available? ? nil : " (gh CLI not available — PR evidence skipped)"
|
|
332
|
+
puts_line " Note: branch staleness reflects last sync#{note}."
|
|
333
|
+
puts_line ""
|
|
334
|
+
|
|
335
|
+
puts_line " Worktrees:"
|
|
336
|
+
if worktree_plan.empty?
|
|
337
|
+
puts_line " none"
|
|
338
|
+
else
|
|
339
|
+
worktree_plan.each do |item|
|
|
340
|
+
label = "#{item[ :name ]} (#{item[ :branch ]})"
|
|
341
|
+
action_str = item[ :action ] == :reap ? "→ would reap" : "→ skip"
|
|
342
|
+
puts_line " #{label.ljust( name_width )} #{item[ :reason ].ljust( reason_width )} #{action_str}"
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
puts_line ""
|
|
347
|
+
puts_line " Stale branches (upstream gone):"
|
|
348
|
+
if stale.empty?
|
|
349
|
+
puts_line " none"
|
|
350
|
+
else
|
|
351
|
+
stale.each { |item| print_branch_plan_item( item: item, name_width: name_width, reason_width: reason_width ) }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
puts_line ""
|
|
355
|
+
puts_line " Orphan branches (no upstream tracking):"
|
|
356
|
+
if orphan.empty?
|
|
357
|
+
puts_line " none"
|
|
358
|
+
else
|
|
359
|
+
orphan.each { |item| print_branch_plan_item( item: item, name_width: name_width, reason_width: reason_width ) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
puts_line ""
|
|
363
|
+
puts_line " Absorbed branches (content already on main):"
|
|
364
|
+
if absorbed.empty?
|
|
365
|
+
puts_line " none"
|
|
366
|
+
else
|
|
367
|
+
absorbed.each { |item| print_branch_plan_item( item: item, name_width: name_width, reason_width: reason_width ) }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
puts_line ""
|
|
371
|
+
puts_line " #{would_apply} would be applied, #{would_skip} skipped."
|
|
372
|
+
puts_line " Run without --dry-run to apply." if would_apply > 0
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Prints one branch plan item with aligned columns.
|
|
376
|
+
def print_branch_plan_item( item:, name_width:, reason_width: )
|
|
377
|
+
action_str = item[ :action ] == :delete ? "→ would delete" : "→ skip"
|
|
378
|
+
puts_line " #{item[ :branch ].ljust( name_width )} #{item[ :reason ].ljust( reason_width )} #{action_str}"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Extracts a short PR reference (e.g. "PR #123") from a GitHub URL.
|
|
382
|
+
def pr_short_ref( url )
|
|
383
|
+
return "PR" if url.nil? || url.empty?
|
|
384
|
+
m = url.match( /\/pull\/(\d+)$/ )
|
|
385
|
+
m ? "PR ##{m[1]}" : "PR"
|
|
386
|
+
end
|
|
181
387
|
end
|
|
182
388
|
|
|
183
389
|
include Housekeep
|
|
@@ -12,7 +12,7 @@ module Carson
|
|
|
12
12
|
return fingerprint_status unless fingerprint_status.nil?
|
|
13
13
|
|
|
14
14
|
unless inside_git_work_tree?
|
|
15
|
-
puts_line "
|
|
15
|
+
puts_line "#{repo_root} is not a git repository."
|
|
16
16
|
return EXIT_ERROR
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -38,7 +38,7 @@ module Carson
|
|
|
38
38
|
return fingerprint_status unless fingerprint_status.nil?
|
|
39
39
|
|
|
40
40
|
unless inside_git_work_tree?
|
|
41
|
-
puts_line "
|
|
41
|
+
puts_line "#{repo_root} is not a git repository."
|
|
42
42
|
return EXIT_ERROR
|
|
43
43
|
end
|
|
44
44
|
|
|
@@ -49,16 +49,17 @@ module Carson
|
|
|
49
49
|
return hook_status unless hook_status == EXIT_OK
|
|
50
50
|
|
|
51
51
|
drift_count = template_results.count { it.fetch( :status ) != "ok" }
|
|
52
|
+
stale_count = template_superseded_present.count
|
|
52
53
|
template_status = template_apply!
|
|
53
54
|
return template_status unless template_status == EXIT_OK
|
|
54
55
|
|
|
55
|
-
@template_sync_result = template_propagate!( drift_count: drift_count )
|
|
56
|
+
@template_sync_result = template_propagate!( drift_count: drift_count + stale_count )
|
|
56
57
|
|
|
57
58
|
audit_status = audit!
|
|
58
59
|
if audit_status == EXIT_OK
|
|
59
60
|
puts_line "OK: Carson refresh completed for #{repo_root}."
|
|
60
61
|
elsif audit_status == EXIT_BLOCK
|
|
61
|
-
puts_line "
|
|
62
|
+
puts_line "Refresh complete — some checks need attention. Run carson audit for details."
|
|
62
63
|
end
|
|
63
64
|
return audit_status
|
|
64
65
|
end
|
|
@@ -69,15 +70,17 @@ module Carson
|
|
|
69
70
|
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
70
71
|
|
|
71
72
|
template_drift_count = template_results.count { it.fetch( :status ) != "ok" }
|
|
73
|
+
stale_count = template_superseded_present.count
|
|
72
74
|
template_status = with_captured_output { template_apply! }
|
|
73
75
|
return template_status unless template_status == EXIT_OK
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
total_drift = template_drift_count + stale_count
|
|
77
|
+
if total_drift.positive?
|
|
78
|
+
puts_line "Templates applied (#{template_drift_count} updated, #{stale_count} removed)."
|
|
76
79
|
else
|
|
77
80
|
puts_line "Templates in sync."
|
|
78
81
|
end
|
|
79
82
|
|
|
80
|
-
@template_sync_result = template_propagate!( drift_count:
|
|
83
|
+
@template_sync_result = template_propagate!( drift_count: total_drift )
|
|
81
84
|
|
|
82
85
|
audit_status = audit!
|
|
83
86
|
puts_line "Refresh complete."
|
|
@@ -109,7 +112,7 @@ module Carson
|
|
|
109
112
|
repos.each do |repo_path|
|
|
110
113
|
repo_name = File.basename( repo_path )
|
|
111
114
|
unless Dir.exist?( repo_path )
|
|
112
|
-
puts_line "#{repo_name}:
|
|
115
|
+
puts_line "#{repo_name}: not found"
|
|
113
116
|
record_batch_skip( command: "refresh", repo_path: repo_path, reason: "path not found" )
|
|
114
117
|
failed += 1
|
|
115
118
|
next
|
|
@@ -157,7 +160,7 @@ module Carson
|
|
|
157
160
|
repos.each do |repo_path|
|
|
158
161
|
repo_name = File.basename( repo_path )
|
|
159
162
|
unless Dir.exist?( repo_path )
|
|
160
|
-
puts_line "#{repo_name}:
|
|
163
|
+
puts_line "#{repo_name}: not found"
|
|
161
164
|
record_batch_skip( command: "prune", repo_path: repo_path, reason: "path not found" )
|
|
162
165
|
failed += 1
|
|
163
166
|
next
|
|
@@ -180,7 +183,7 @@ module Carson
|
|
|
180
183
|
succeeded += 1
|
|
181
184
|
end
|
|
182
185
|
rescue StandardError => exception
|
|
183
|
-
puts_line "#{repo_name}:
|
|
186
|
+
puts_line "#{repo_name}: could not complete (#{exception.message})"
|
|
184
187
|
record_batch_skip( command: "prune", repo_path: repo_path, reason: exception.message )
|
|
185
188
|
failed += 1
|
|
186
189
|
end
|
|
@@ -196,7 +199,7 @@ module Carson
|
|
|
196
199
|
puts_verbose ""
|
|
197
200
|
puts_verbose "[Offboard]"
|
|
198
201
|
unless inside_git_work_tree?
|
|
199
|
-
puts_line "
|
|
202
|
+
puts_line "#{repo_root} is not a git repository."
|
|
200
203
|
return EXIT_ERROR
|
|
201
204
|
end
|
|
202
205
|
if self.in.respond_to?( :tty? ) && self.in.tty?
|
|
@@ -266,15 +269,12 @@ module Carson
|
|
|
266
269
|
auto_register_govern!
|
|
267
270
|
|
|
268
271
|
puts_line ""
|
|
269
|
-
puts_line "Your repository is set up.
|
|
270
|
-
puts_line "
|
|
271
|
-
puts_line "
|
|
272
|
-
puts_line "
|
|
273
|
-
puts_line "
|
|
274
|
-
puts_line "
|
|
275
|
-
puts_line ""
|
|
276
|
-
puts_line "Before your first push, have a look through .github/ to"
|
|
277
|
-
puts_line "make sure everything is to your liking."
|
|
272
|
+
puts_line "Your repository is set up. If you have configured"
|
|
273
|
+
puts_line "lint.canonical, Carson has placed your canonical"
|
|
274
|
+
puts_line "policy files in the project's .github/ directory."
|
|
275
|
+
puts_line "Once pushed to GitHub, they'll ensure every pull"
|
|
276
|
+
puts_line "request follows a consistent standard and all"
|
|
277
|
+
puts_line "checks run automatically."
|
|
278
278
|
puts_line ""
|
|
279
279
|
puts_line "To adjust any setting: carson setup"
|
|
280
280
|
|
|
@@ -322,7 +322,7 @@ module Carson
|
|
|
322
322
|
if git_remote_exists?( remote_name: config.git_remote )
|
|
323
323
|
puts_verbose "remote_ok: #{config.git_remote}"
|
|
324
324
|
else
|
|
325
|
-
puts_line "
|
|
325
|
+
puts_line "Remote '#{config.git_remote}' not found — run carson setup to configure."
|
|
326
326
|
end
|
|
327
327
|
end
|
|
328
328
|
|
|
@@ -349,7 +349,7 @@ module Carson
|
|
|
349
349
|
puts_line "#{repo_name}: #{label}#{sync_suffix}"
|
|
350
350
|
status
|
|
351
351
|
rescue StandardError => exception
|
|
352
|
-
puts_line "#{repo_name}:
|
|
352
|
+
puts_line "#{repo_name}: could not complete (#{exception.message})"
|
|
353
353
|
EXIT_ERROR
|
|
354
354
|
end
|
|
355
355
|
|
|
@@ -357,7 +357,7 @@ module Carson
|
|
|
357
357
|
case status
|
|
358
358
|
when EXIT_OK then "OK"
|
|
359
359
|
when EXIT_BLOCK then "BLOCK"
|
|
360
|
-
else "
|
|
360
|
+
else "incomplete"
|
|
361
361
|
end
|
|
362
362
|
end
|
|
363
363
|
|
|
@@ -377,7 +377,7 @@ module Carson
|
|
|
377
377
|
puts_verbose "hooks_path_unset: core.hooksPath"
|
|
378
378
|
EXIT_OK
|
|
379
379
|
rescue StandardError => exception
|
|
380
|
-
puts_line "
|
|
380
|
+
puts_line "Could not update core.hooksPath: #{exception.message}"
|
|
381
381
|
EXIT_ERROR
|
|
382
382
|
end
|
|
383
383
|
|