carson 3.22.0 → 3.23.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/API.md +19 -20
- data/MANUAL.md +76 -65
- data/README.md +42 -50
- data/RELEASE.md +24 -1
- data/SKILL.md +1 -1
- data/VERSION +1 -1
- data/carson.gemspec +3 -4
- data/hooks/command-guard +1 -1
- data/hooks/pre-push +17 -20
- data/lib/carson/adapters/agent.rb +2 -2
- data/lib/carson/branch.rb +38 -0
- data/lib/carson/cli.rb +45 -30
- data/lib/carson/config.rb +80 -29
- data/lib/carson/delivery.rb +64 -0
- data/lib/carson/ledger.rb +305 -0
- data/lib/carson/repository.rb +47 -0
- data/lib/carson/revision.rb +30 -0
- data/lib/carson/runtime/audit.rb +43 -17
- data/lib/carson/runtime/deliver.rb +163 -149
- data/lib/carson/runtime/govern.rb +233 -357
- data/lib/carson/runtime/housekeep.rb +233 -27
- data/lib/carson/runtime/local/onboard.rb +29 -29
- data/lib/carson/runtime/local/prune.rb +120 -35
- data/lib/carson/runtime/local/sync.rb +29 -7
- data/lib/carson/runtime/local/template.rb +30 -12
- data/lib/carson/runtime/local/worktree.rb +37 -442
- data/lib/carson/runtime/review/gate_support.rb +144 -12
- data/lib/carson/runtime/review/sweep_support.rb +2 -2
- data/lib/carson/runtime/review/utility.rb +1 -1
- data/lib/carson/runtime/review.rb +21 -77
- data/lib/carson/runtime/setup.rb +25 -33
- data/lib/carson/runtime/status.rb +96 -212
- data/lib/carson/runtime.rb +39 -4
- data/lib/carson/worktree.rb +497 -0
- data/lib/carson.rb +6 -0
- metadata +37 -17
- 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
data/carson.gemspec
CHANGED
|
@@ -7,8 +7,8 @@ Gem::Specification.new do |spec|
|
|
|
7
7
|
spec.version = Carson::VERSION
|
|
8
8
|
spec.authors = [ "Hailei Wang", "Codex", "Claude Code" ]
|
|
9
9
|
spec.email = [ "wanghailei@users.noreply.github.com" ]
|
|
10
|
-
spec.summary = "Autonomous
|
|
11
|
-
spec.description = "Carson is
|
|
10
|
+
spec.summary = "Autonomous git strategist and repositories governor — you write the code, Carson manages everything else."
|
|
11
|
+
spec.description = "Carson is an autonomous git strategist and repositories governor that lives outside the repositories it governs — no Carson-owned artefacts in your repo. As strategist, Carson knows when to branch, how to isolate concurrent work, and how to recover from failures. As governor, it enforces review gates, manages templates, and triages every open PR across your portfolio: merge what's ready, dispatch coding agents to fix what's failing, escalate what needs human judgement. One command, all your projects, unmanned."
|
|
12
12
|
spec.homepage = "https://github.com/wanghailei/carson"
|
|
13
13
|
spec.license = "PolyForm-Shield-1.0.0"
|
|
14
14
|
spec.required_ruby_version = ">= 3.4"
|
|
@@ -28,9 +28,8 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
spec.bindir = "exe"
|
|
29
29
|
spec.executables = [ "carson" ]
|
|
30
30
|
spec.require_paths = [ "lib" ]
|
|
31
|
+
spec.add_dependency "sqlite3", ">= 1.3", "< 3"
|
|
31
32
|
spec.files = Dir.glob( "{lib,exe,templates,hooks}/**/*", File::FNM_DOTMATCH ).select { |path| File.file?( path ) } + [
|
|
32
|
-
".github/copilot-instructions.md",
|
|
33
|
-
".github/pull_request_template.md",
|
|
34
33
|
".github/workflows/carson_policy.yml",
|
|
35
34
|
"README.md",
|
|
36
35
|
"MANUAL.md",
|
data/hooks/command-guard
CHANGED
|
@@ -53,6 +53,6 @@ if ! grep -qF "\"$normalised\"" "$config_file" 2>/dev/null; then
|
|
|
53
53
|
fi
|
|
54
54
|
|
|
55
55
|
# This is a raw gh pr command in a governed repo — block it.
|
|
56
|
-
echo "
|
|
56
|
+
echo "This repo is Carson-governed — use \`carson deliver\` instead of raw \`gh pr\`." >&2
|
|
57
57
|
echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
|
|
58
58
|
exit 2
|
data/hooks/pre-push
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
# Guards:
|
|
5
5
|
# 1. Blocks direct push to main/master refs.
|
|
6
6
|
# 2. Blocks raw git push in governed repos — agents must use `carson deliver`.
|
|
7
|
-
# Carson
|
|
7
|
+
# Carson bypasses via --no-verify internally. No env-var signal to spoof.
|
|
8
8
|
#
|
|
9
|
-
# Bypass
|
|
9
|
+
# Bypass: git push --no-verify
|
|
10
10
|
set -euo pipefail
|
|
11
11
|
|
|
12
12
|
hooks_dir="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -21,9 +21,9 @@ has_commit_push=false
|
|
|
21
21
|
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
22
22
|
case "$remote_ref" in
|
|
23
23
|
refs/heads/main|refs/heads/master)
|
|
24
|
-
echo "
|
|
24
|
+
echo "Pushes to ${remote_ref#refs/heads/} go through PRs, not direct push." >&2
|
|
25
25
|
echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
|
|
26
|
-
echo "Bypass
|
|
26
|
+
echo "Bypass: git push --no-verify" >&2
|
|
27
27
|
exit 1
|
|
28
28
|
;;
|
|
29
29
|
esac
|
|
@@ -31,23 +31,20 @@ while read -r local_ref local_sha remote_ref remote_sha; do
|
|
|
31
31
|
done
|
|
32
32
|
|
|
33
33
|
# --- Guard 2: block raw git push in governed repos ---
|
|
34
|
-
#
|
|
35
|
-
#
|
|
34
|
+
# All pushes in governed repos are blocked unconditionally.
|
|
35
|
+
# Carson bypasses this hook via --no-verify when pushing internally.
|
|
36
|
+
# No env-var bypass — cannot be spoofed.
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
|
|
48
|
-
echo "Bypass (emergency only): git push --no-verify" >&2
|
|
49
|
-
exit 1
|
|
50
|
-
fi
|
|
38
|
+
config_file="${HOME}/.carson/config.json"
|
|
39
|
+
if [[ -f "$config_file" ]]; then
|
|
40
|
+
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
|
|
41
|
+
if [[ -n "$repo_root" ]]; then
|
|
42
|
+
normalised="$(cd "$repo_root" && pwd -P)"
|
|
43
|
+
if grep -qF "\"$normalised\"" "$config_file" 2>/dev/null; then
|
|
44
|
+
echo "This repo is Carson-governed — use \`carson deliver\` instead of raw \`git push\`." >&2
|
|
45
|
+
echo "Use \`carson deliver\` instead — it handles push, PR, and merge with safety guards." >&2
|
|
46
|
+
echo "Bypass: git push --no-verify" >&2
|
|
47
|
+
exit 1
|
|
51
48
|
fi
|
|
52
49
|
fi
|
|
53
50
|
fi
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
module Carson
|
|
3
3
|
module Adapters
|
|
4
4
|
module Agent
|
|
5
|
-
WorkOrder =
|
|
5
|
+
WorkOrder = Struct.new( :repo, :branch, :pr_number, :objective, :context, :acceptance_checks, keyword_init: true )
|
|
6
6
|
# objective: "fix_ci" | "address_review" | "fix_audit"
|
|
7
7
|
# context: String (legacy — PR title) or Hash with structured evidence:
|
|
8
8
|
# fix_ci: { title:, ci_logs:, ci_run_url:, prior_attempt: { summary:, dispatched_at: } }
|
|
9
9
|
# address_review: { title:, review_findings: [{ kind:, url:, body: }], prior_attempt: ... }
|
|
10
10
|
# acceptance_checks: what must pass for the fix to be accepted
|
|
11
11
|
|
|
12
|
-
Result =
|
|
12
|
+
Result = Struct.new( :status, :summary, :evidence, :commit_sha, keyword_init: true )
|
|
13
13
|
# status: "done" | "failed" | "timeout"
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Passive branch record. Branch holds identity and current facts only.
|
|
2
|
+
module Carson
|
|
3
|
+
class Branch
|
|
4
|
+
attr_reader :repository, :name, :purpose, :head, :worktree, :delivery
|
|
5
|
+
|
|
6
|
+
def initialize( repository:, name:, runtime:, purpose: nil, head: nil, worktree: nil, delivery: nil )
|
|
7
|
+
@repository = repository
|
|
8
|
+
@name = name
|
|
9
|
+
@runtime = runtime
|
|
10
|
+
@purpose = purpose
|
|
11
|
+
@head = head
|
|
12
|
+
@worktree = worktree
|
|
13
|
+
@delivery = delivery
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Re-reads the branch facts from git and Carson's ledger.
|
|
17
|
+
def reload
|
|
18
|
+
refreshed_head = runtime.git_capture!( "rev-parse", name ).strip
|
|
19
|
+
refreshed_worktree = runtime.worktree_list.find { |entry| entry.branch == name }&.path || worktree
|
|
20
|
+
refreshed_delivery = runtime.ledger.active_delivery( repo_path: repository.path, branch_name: name )
|
|
21
|
+
self.class.new(
|
|
22
|
+
repository: repository,
|
|
23
|
+
name: name,
|
|
24
|
+
runtime: runtime,
|
|
25
|
+
purpose: purpose,
|
|
26
|
+
head: refreshed_head,
|
|
27
|
+
worktree: refreshed_worktree,
|
|
28
|
+
delivery: refreshed_delivery
|
|
29
|
+
)
|
|
30
|
+
rescue StandardError
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :runtime
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/carson/cli.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Carson
|
|
|
24
24
|
target_repo_root = parsed.fetch( :repo_root, nil )
|
|
25
25
|
target_repo_root = repo_root if target_repo_root.to_s.strip.empty?
|
|
26
26
|
unless Dir.exist?( target_repo_root )
|
|
27
|
-
error.puts "#{BADGE}
|
|
27
|
+
error.puts "#{BADGE} Repository path not found: #{target_repo_root}"
|
|
28
28
|
return Runtime::EXIT_ERROR
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -32,10 +32,10 @@ module Carson
|
|
|
32
32
|
runtime = Runtime.new( repo_root: target_repo_root, tool_root: tool_root, output: output, error: error, verbose: verbose )
|
|
33
33
|
dispatch( parsed: parsed, runtime: runtime )
|
|
34
34
|
rescue ConfigError => exception
|
|
35
|
-
error.puts "#{BADGE}
|
|
35
|
+
error.puts "#{BADGE} Configuration problem: #{exception.message}"
|
|
36
36
|
Runtime::EXIT_ERROR
|
|
37
37
|
rescue StandardError => exception
|
|
38
|
-
error.puts "#{BADGE}
|
|
38
|
+
error.puts "#{BADGE} #{exception.message}"
|
|
39
39
|
Runtime::EXIT_ERROR
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -61,11 +61,11 @@ module Carson
|
|
|
61
61
|
parser.separator "Repository governance and workflow automation for coding agents."
|
|
62
62
|
parser.separator ""
|
|
63
63
|
parser.separator "Commands:"
|
|
64
|
-
parser.separator " status Show repository state
|
|
64
|
+
parser.separator " status Show repository delivery state"
|
|
65
65
|
parser.separator " setup Initialise Carson configuration"
|
|
66
66
|
parser.separator " audit Run pre-commit health checks"
|
|
67
67
|
parser.separator " sync Sync local main with remote"
|
|
68
|
-
parser.separator " deliver
|
|
68
|
+
parser.separator " deliver Start autonomous branch delivery"
|
|
69
69
|
parser.separator " prune Remove stale local branches"
|
|
70
70
|
parser.separator " worktree Manage isolated coding worktrees"
|
|
71
71
|
parser.separator " housekeep Sync, reap worktrees, and prune branches"
|
|
@@ -138,7 +138,7 @@ module Carson
|
|
|
138
138
|
def self.parse_setup_command( arguments:, error: )
|
|
139
139
|
options = {}
|
|
140
140
|
setup_parser = OptionParser.new do |parser|
|
|
141
|
-
parser.banner = "Usage: carson setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--
|
|
141
|
+
parser.banner = "Usage: carson setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--canonical PATH]"
|
|
142
142
|
parser.separator ""
|
|
143
143
|
parser.separator "Initialise Carson configuration for the current repository."
|
|
144
144
|
parser.separator "Detects git remote, main branch, and workflow style, then writes .carson.yml."
|
|
@@ -148,13 +148,11 @@ module Carson
|
|
|
148
148
|
parser.on( "--remote NAME", "Git remote name" ) { |value| options[ "git.remote" ] = value }
|
|
149
149
|
parser.on( "--main-branch NAME", "Main branch name" ) { |value| options[ "git.main_branch" ] = value }
|
|
150
150
|
parser.on( "--workflow STYLE", "Workflow style (branch or trunk)" ) { |value| options[ "workflow.style" ] = value }
|
|
151
|
-
parser.on( "--
|
|
152
|
-
parser.on( "--canonical PATH", "Canonical template directory path" ) { |value| options[ "template.canonical" ] = value }
|
|
151
|
+
parser.on( "--canonical PATH", "Canonical lint policy directory path" ) { |value| options[ "lint.canonical" ] = value }
|
|
153
152
|
parser.separator ""
|
|
154
153
|
parser.separator "Examples:"
|
|
155
154
|
parser.separator " carson setup Auto-detect and write config"
|
|
156
155
|
parser.separator " carson setup --remote github Use 'github' as the git remote"
|
|
157
|
-
parser.separator " carson setup --merge squash Set squash as the merge method"
|
|
158
156
|
end
|
|
159
157
|
setup_parser.parse!( arguments )
|
|
160
158
|
unless arguments.empty?
|
|
@@ -165,6 +163,7 @@ module Carson
|
|
|
165
163
|
{ command: "setup", cli_choices: options }
|
|
166
164
|
rescue OptionParser::ParseError => exception
|
|
167
165
|
error.puts "#{BADGE} #{exception.message}"
|
|
166
|
+
error.puts setup_parser
|
|
168
167
|
{ command: :invalid }
|
|
169
168
|
end
|
|
170
169
|
|
|
@@ -195,6 +194,7 @@ module Carson
|
|
|
195
194
|
}
|
|
196
195
|
rescue OptionParser::ParseError => exception
|
|
197
196
|
error.puts "#{BADGE} #{exception.message}"
|
|
197
|
+
error.puts onboard_parser
|
|
198
198
|
{ command: :invalid }
|
|
199
199
|
end
|
|
200
200
|
|
|
@@ -222,6 +222,7 @@ module Carson
|
|
|
222
222
|
}
|
|
223
223
|
rescue OptionParser::ParseError => exception
|
|
224
224
|
error.puts "#{BADGE} #{exception.message}"
|
|
225
|
+
error.puts offboard_parser
|
|
225
226
|
{ command: :invalid }
|
|
226
227
|
end
|
|
227
228
|
|
|
@@ -265,6 +266,7 @@ module Carson
|
|
|
265
266
|
}
|
|
266
267
|
rescue OptionParser::ParseError => exception
|
|
267
268
|
error.puts "#{BADGE} #{exception.message}"
|
|
269
|
+
error.puts refresh_parser
|
|
268
270
|
{ command: :invalid }
|
|
269
271
|
end
|
|
270
272
|
|
|
@@ -292,6 +294,7 @@ module Carson
|
|
|
292
294
|
{ command: "prune", json: options[ :json ] }
|
|
293
295
|
rescue OptionParser::ParseError => exception
|
|
294
296
|
error.puts "#{BADGE} #{exception.message}"
|
|
297
|
+
error.puts prune_parser
|
|
295
298
|
{ command: :invalid }
|
|
296
299
|
end
|
|
297
300
|
|
|
@@ -348,6 +351,7 @@ module Carson
|
|
|
348
351
|
end
|
|
349
352
|
rescue OptionParser::ParseError => exception
|
|
350
353
|
error.puts "#{BADGE} #{exception.message}"
|
|
354
|
+
error.puts worktree_parser
|
|
351
355
|
{ command: :invalid }
|
|
352
356
|
end
|
|
353
357
|
|
|
@@ -378,6 +382,7 @@ module Carson
|
|
|
378
382
|
{ command: "review:#{action}" }
|
|
379
383
|
rescue OptionParser::ParseError => exception
|
|
380
384
|
error.puts "#{BADGE} #{exception.message}"
|
|
385
|
+
error.puts review_parser
|
|
381
386
|
{ command: :invalid }
|
|
382
387
|
end
|
|
383
388
|
|
|
@@ -436,6 +441,7 @@ module Carson
|
|
|
436
441
|
{ command: "template:apply", push_prep: options.fetch( :push_prep ) }
|
|
437
442
|
rescue OptionParser::ParseError => exception
|
|
438
443
|
error.puts "#{BADGE} #{exception.message}"
|
|
444
|
+
error.puts( apply_parser || template_parser )
|
|
439
445
|
{ command: :invalid }
|
|
440
446
|
end
|
|
441
447
|
|
|
@@ -468,6 +474,7 @@ module Carson
|
|
|
468
474
|
{ command: "audit", json: options[ :json ] }
|
|
469
475
|
rescue OptionParser::ParseError => exception
|
|
470
476
|
error.puts "#{BADGE} #{exception.message}"
|
|
477
|
+
error.puts audit_parser
|
|
471
478
|
{ command: :invalid }
|
|
472
479
|
end
|
|
473
480
|
|
|
@@ -499,6 +506,7 @@ module Carson
|
|
|
499
506
|
{ command: "sync", json: options[ :json ] }
|
|
500
507
|
rescue OptionParser::ParseError => exception
|
|
501
508
|
error.puts "#{BADGE} #{exception.message}"
|
|
509
|
+
error.puts sync_parser
|
|
502
510
|
{ command: :invalid }
|
|
503
511
|
end
|
|
504
512
|
|
|
@@ -530,28 +538,32 @@ module Carson
|
|
|
530
538
|
{ command: "status", json: options[ :json ] }
|
|
531
539
|
rescue OptionParser::ParseError => exception
|
|
532
540
|
error.puts "#{BADGE} #{exception.message}"
|
|
541
|
+
error.puts status_parser
|
|
533
542
|
{ command: :invalid }
|
|
534
543
|
end
|
|
535
544
|
|
|
536
545
|
# --- deliver ---
|
|
537
546
|
|
|
538
547
|
def self.parse_deliver_command( arguments:, error: )
|
|
539
|
-
|
|
548
|
+
if arguments.include?( "--merge" )
|
|
549
|
+
error.puts "#{BADGE} carson deliver --merge is no longer supported; use carson deliver"
|
|
550
|
+
return { command: :invalid }
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
options = { json: false, title: nil, body_file: nil }
|
|
540
554
|
deliver_parser = OptionParser.new do |parser|
|
|
541
|
-
parser.banner = "Usage: carson deliver [--
|
|
555
|
+
parser.banner = "Usage: carson deliver [--json] [--title TITLE] [--body-file PATH]"
|
|
542
556
|
parser.separator ""
|
|
543
|
-
parser.separator "Push the current branch, create
|
|
544
|
-
parser.separator "
|
|
557
|
+
parser.separator "Push the current branch, create or refresh the pull request, and hand the branch to Carson."
|
|
558
|
+
parser.separator "Carson records delivery state and continues from there."
|
|
545
559
|
parser.separator ""
|
|
546
560
|
parser.separator "Options:"
|
|
547
|
-
parser.on( "--merge", "Also merge the PR if CI passes" ) { options[ :merge ] = true }
|
|
548
561
|
parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
549
562
|
parser.on( "--title TITLE", "PR title (defaults to branch name)" ) { |value| options[ :title ] = value }
|
|
550
563
|
parser.on( "--body-file PATH", "File containing PR body text" ) { |value| options[ :body_file ] = value }
|
|
551
564
|
parser.separator ""
|
|
552
565
|
parser.separator "Examples:"
|
|
553
|
-
parser.separator " carson deliver Push
|
|
554
|
-
parser.separator " carson deliver --merge Push, open a PR, and merge if CI passes"
|
|
566
|
+
parser.separator " carson deliver Push, open a PR, and register delivery state"
|
|
555
567
|
end
|
|
556
568
|
deliver_parser.parse!( arguments )
|
|
557
569
|
unless arguments.empty?
|
|
@@ -561,13 +573,13 @@ module Carson
|
|
|
561
573
|
end
|
|
562
574
|
{
|
|
563
575
|
command: "deliver",
|
|
564
|
-
merge: options.fetch( :merge ),
|
|
565
576
|
json: options.fetch( :json ),
|
|
566
577
|
title: options[ :title ],
|
|
567
578
|
body_file: options[ :body_file ]
|
|
568
579
|
}
|
|
569
580
|
rescue OptionParser::ParseError => exception
|
|
570
581
|
error.puts "#{BADGE} #{exception.message}"
|
|
582
|
+
error.puts deliver_parser
|
|
571
583
|
{ command: :invalid }
|
|
572
584
|
end
|
|
573
585
|
|
|
@@ -596,27 +608,30 @@ module Carson
|
|
|
596
608
|
{ command: "repos", json: options[ :json ] }
|
|
597
609
|
rescue OptionParser::ParseError => exception
|
|
598
610
|
error.puts "#{BADGE} #{exception.message}"
|
|
611
|
+
error.puts repos_parser
|
|
599
612
|
{ command: :invalid }
|
|
600
613
|
end
|
|
601
614
|
|
|
602
615
|
# --- housekeep ---
|
|
603
616
|
|
|
604
617
|
def self.parse_housekeep_command( arguments:, error: )
|
|
605
|
-
options = { all: false, json: false }
|
|
618
|
+
options = { all: false, json: false, dry_run: false }
|
|
606
619
|
housekeep_parser = OptionParser.new do |parser|
|
|
607
|
-
parser.banner = "Usage: carson housekeep [REPO] [--all] [--json]"
|
|
620
|
+
parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json]"
|
|
608
621
|
parser.separator ""
|
|
609
622
|
parser.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches."
|
|
610
623
|
parser.separator "Defaults to the current repository."
|
|
611
624
|
parser.separator ""
|
|
612
625
|
parser.separator "Options:"
|
|
613
626
|
parser.on( "--all", "Housekeep all governed repositories" ) { options[ :all ] = true }
|
|
627
|
+
parser.on( "--dry-run", "Show what would be reaped/deleted without making changes" ) { options[ :dry_run ] = true }
|
|
614
628
|
parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
615
629
|
parser.separator ""
|
|
616
630
|
parser.separator "Examples:"
|
|
617
|
-
parser.separator " carson housekeep
|
|
618
|
-
parser.separator " carson housekeep
|
|
619
|
-
parser.separator " carson housekeep
|
|
631
|
+
parser.separator " carson housekeep Housekeep the current repository"
|
|
632
|
+
parser.separator " carson housekeep --dry-run Preview what housekeep would do"
|
|
633
|
+
parser.separator " carson housekeep nexus Housekeep a named governed repo"
|
|
634
|
+
parser.separator " carson housekeep --all Housekeep all governed repos"
|
|
620
635
|
end
|
|
621
636
|
housekeep_parser.parse!( arguments )
|
|
622
637
|
|
|
@@ -625,7 +640,7 @@ module Carson
|
|
|
625
640
|
return { command: :invalid }
|
|
626
641
|
end
|
|
627
642
|
|
|
628
|
-
return { command: "housekeep:all", json: options[ :json ] } if options[ :all ]
|
|
643
|
+
return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ] } if options[ :all ]
|
|
629
644
|
|
|
630
645
|
if arguments.length > 1
|
|
631
646
|
error.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
|
|
@@ -633,11 +648,12 @@ module Carson
|
|
|
633
648
|
end
|
|
634
649
|
|
|
635
650
|
target = arguments.shift
|
|
636
|
-
return { command: "housekeep:target", target: target, json: options[ :json ] } if target
|
|
651
|
+
return { command: "housekeep:target", target: target, json: options[ :json ], dry_run: options[ :dry_run ] } if target
|
|
637
652
|
|
|
638
|
-
{ command: "housekeep", json: options[ :json ] }
|
|
653
|
+
{ command: "housekeep", json: options[ :json ], dry_run: options[ :dry_run ] }
|
|
639
654
|
rescue OptionParser::ParseError => exception
|
|
640
655
|
error.puts "#{BADGE} #{exception.message}"
|
|
656
|
+
error.puts housekeep_parser
|
|
641
657
|
{ command: :invalid }
|
|
642
658
|
end
|
|
643
659
|
|
|
@@ -660,7 +676,7 @@ module Carson
|
|
|
660
676
|
parser.on( "--dry-run", "Run all checks but do not merge or dispatch" ) { options[ :dry_run ] = true }
|
|
661
677
|
parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
662
678
|
parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles" ) do |seconds|
|
|
663
|
-
error.puts( "#{BADGE}
|
|
679
|
+
error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
|
|
664
680
|
options[ :loop_seconds ] = seconds
|
|
665
681
|
end
|
|
666
682
|
parser.separator ""
|
|
@@ -745,7 +761,6 @@ module Carson
|
|
|
745
761
|
runtime.template_apply!( push_prep: parsed.fetch( :push_prep, false ) )
|
|
746
762
|
when "deliver"
|
|
747
763
|
runtime.deliver!(
|
|
748
|
-
merge: parsed.fetch( :merge, false ),
|
|
749
764
|
title: parsed.fetch( :title, nil ),
|
|
750
765
|
body_file: parsed.fetch( :body_file, nil ),
|
|
751
766
|
json_output: parsed.fetch( :json, false )
|
|
@@ -757,11 +772,11 @@ module Carson
|
|
|
757
772
|
when "repos"
|
|
758
773
|
runtime.repos!( json_output: parsed.fetch( :json, false ) )
|
|
759
774
|
when "housekeep"
|
|
760
|
-
runtime.housekeep!( json_output: parsed.fetch( :json, false ) )
|
|
775
|
+
runtime.housekeep!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
|
|
761
776
|
when "housekeep:target"
|
|
762
|
-
runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) )
|
|
777
|
+
runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
|
|
763
778
|
when "housekeep:all"
|
|
764
|
-
runtime.housekeep_all!( json_output: parsed.fetch( :json, false ) )
|
|
779
|
+
runtime.housekeep_all!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
|
|
765
780
|
when "govern"
|
|
766
781
|
runtime.govern!(
|
|
767
782
|
dry_run: parsed.fetch( :dry_run, false ),
|
data/lib/carson/config.rb
CHANGED
|
@@ -7,16 +7,31 @@ module Carson
|
|
|
7
7
|
|
|
8
8
|
# Config is built-in only for outsider mode; host repositories do not carry Carson config files.
|
|
9
9
|
class Config
|
|
10
|
+
CANONICAL_GITHUB_DIRECTORIES = %w[actions workflows ISSUE_TEMPLATE DISCUSSION_TEMPLATE linters].freeze
|
|
11
|
+
CANONICAL_GITHUB_ROOT_FILES = [
|
|
12
|
+
".mega-linter.yml",
|
|
13
|
+
"AGENTS.md",
|
|
14
|
+
"CLAUDE.md",
|
|
15
|
+
"CODEOWNERS",
|
|
16
|
+
"FUNDING.yml",
|
|
17
|
+
"carson.md",
|
|
18
|
+
"copilot-instructions.md",
|
|
19
|
+
"dependabot.yml",
|
|
20
|
+
"funding.yml",
|
|
21
|
+
"labeler.yml",
|
|
22
|
+
"pull_request_template.md"
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
10
25
|
attr_accessor :git_remote
|
|
11
26
|
attr_reader :main_branch, :protected_branches, :hooks_path, :managed_hooks,
|
|
12
|
-
:template_managed_files, :template_canonical,
|
|
27
|
+
:template_managed_files, :lint_canonical, :template_canonical,
|
|
13
28
|
:review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
|
|
14
29
|
:review_sweep_states, :review_disposition, :review_risk_keywords,
|
|
15
30
|
:review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
|
|
16
31
|
:audit_advisory_check_names,
|
|
17
32
|
:workflow_style,
|
|
18
|
-
:govern_repos, :
|
|
19
|
-
:govern_agent_provider, :
|
|
33
|
+
:govern_repos, :govern_authority, :govern_merge_method,
|
|
34
|
+
:govern_agent_provider, :govern_state_path,
|
|
20
35
|
:govern_check_wait
|
|
21
36
|
|
|
22
37
|
def self.load( repo_root: )
|
|
@@ -38,7 +53,10 @@ module Carson
|
|
|
38
53
|
"managed" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
|
|
39
54
|
},
|
|
40
55
|
"template" => {
|
|
41
|
-
"managed_files" => [
|
|
56
|
+
"managed_files" => [],
|
|
57
|
+
"canonical" => nil
|
|
58
|
+
},
|
|
59
|
+
"lint" => {
|
|
42
60
|
"canonical" => nil
|
|
43
61
|
},
|
|
44
62
|
"workflow" => {
|
|
@@ -65,7 +83,7 @@ module Carson
|
|
|
65
83
|
},
|
|
66
84
|
"govern" => {
|
|
67
85
|
"repos" => [],
|
|
68
|
-
"
|
|
86
|
+
"authority" => "remote",
|
|
69
87
|
"merge" => {
|
|
70
88
|
"method" => "squash"
|
|
71
89
|
},
|
|
@@ -74,7 +92,7 @@ module Carson
|
|
|
74
92
|
"codex" => {},
|
|
75
93
|
"claude" => {}
|
|
76
94
|
},
|
|
77
|
-
"
|
|
95
|
+
"state_path" => "~/.carson/state.sqlite3",
|
|
78
96
|
"check_wait" => 30
|
|
79
97
|
}
|
|
80
98
|
}
|
|
@@ -153,8 +171,8 @@ module Carson
|
|
|
153
171
|
govern = fetch_hash_section( data: copy, key: "govern" )
|
|
154
172
|
govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
|
|
155
173
|
govern[ "repos" ] = govern_repos unless govern_repos.empty?
|
|
156
|
-
|
|
157
|
-
govern[ "
|
|
174
|
+
govern_authority = ENV.fetch( "CARSON_GOVERN_AUTHORITY", "" ).to_s.strip
|
|
175
|
+
govern[ "authority" ] = govern_authority unless govern_authority.empty?
|
|
158
176
|
govern_method = ENV.fetch( "CARSON_GOVERN_MERGE_METHOD", "" ).to_s.strip
|
|
159
177
|
unless govern_method.empty?
|
|
160
178
|
govern[ "merge" ] ||= {}
|
|
@@ -191,11 +209,17 @@ module Carson
|
|
|
191
209
|
@main_branch = fetch_string( hash: fetch_hash( hash: data, key: "git" ), key: "main_branch" )
|
|
192
210
|
@protected_branches = fetch_string_array( hash: fetch_hash( hash: data, key: "git" ), key: "protected_branches" )
|
|
193
211
|
|
|
194
|
-
@hooks_path =
|
|
212
|
+
@hooks_path = resolve_runtime_path(
|
|
213
|
+
path: fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "path" ),
|
|
214
|
+
fallback_leaf: "hooks"
|
|
215
|
+
)
|
|
195
216
|
@managed_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "managed" )
|
|
196
217
|
|
|
197
|
-
|
|
198
|
-
@
|
|
218
|
+
template_hash = fetch_hash( hash: data, key: "template" )
|
|
219
|
+
@template_managed_files = fetch_optional_string_array( hash: template_hash, key: "managed_files" )
|
|
220
|
+
@lint_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "lint" ), key: "canonical" )
|
|
221
|
+
@lint_canonical ||= fetch_optional_path( hash: template_hash, key: "canonical" )
|
|
222
|
+
@template_canonical = @lint_canonical
|
|
199
223
|
resolve_canonical_files!
|
|
200
224
|
|
|
201
225
|
workflow_hash = fetch_hash( hash: data, key: "workflow" )
|
|
@@ -219,13 +243,15 @@ module Carson
|
|
|
219
243
|
|
|
220
244
|
govern_hash = fetch_hash( hash: data, key: "govern" )
|
|
221
245
|
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
|
|
222
|
-
@
|
|
246
|
+
@govern_authority = fetch_string( hash: govern_hash, key: "authority" ).downcase
|
|
223
247
|
govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
|
|
224
248
|
@govern_merge_method = fetch_string( hash: govern_merge_hash, key: "method" ).downcase
|
|
225
249
|
govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
|
|
226
250
|
@govern_agent_provider = fetch_string( hash: govern_agent_hash, key: "provider" ).downcase
|
|
227
|
-
|
|
228
|
-
|
|
251
|
+
@govern_state_path = resolve_runtime_path(
|
|
252
|
+
path: govern_hash.fetch( "state_path" ).to_s,
|
|
253
|
+
fallback_leaf: "state.sqlite3"
|
|
254
|
+
)
|
|
229
255
|
@govern_check_wait = fetch_non_negative_integer( hash: govern_hash, key: "check_wait" )
|
|
230
256
|
|
|
231
257
|
validate!
|
|
@@ -246,7 +272,8 @@ module Carson
|
|
|
246
272
|
raise ConfigError, "review.tracking_issue.title cannot be empty" if review_tracking_issue_title.empty?
|
|
247
273
|
raise ConfigError, "review.tracking_issue.label cannot be empty" if review_tracking_issue_label.empty?
|
|
248
274
|
raise ConfigError, "workflow.style must be one of trunk, branch" unless [ "trunk", "branch" ].include?( workflow_style )
|
|
249
|
-
raise ConfigError, "govern.
|
|
275
|
+
raise ConfigError, "govern.authority must be one of remote, local" unless [ "remote", "local" ].include?( govern_authority )
|
|
276
|
+
raise ConfigError, "govern.merge.method must be squash" unless govern_merge_method == "squash"
|
|
250
277
|
raise ConfigError, "govern.agent.provider must be one of auto, codex, claude" unless [ "auto", "codex", "claude" ].include?( govern_agent_provider )
|
|
251
278
|
end
|
|
252
279
|
|
|
@@ -298,13 +325,6 @@ module Carson
|
|
|
298
325
|
rescue ArgumentError, TypeError
|
|
299
326
|
raise ConfigError, "config key #{key} must be an integer"
|
|
300
327
|
end
|
|
301
|
-
def fetch_optional_boolean( hash:, key:, default:, key_path: nil )
|
|
302
|
-
value = hash.fetch( key, default )
|
|
303
|
-
return true if value == true
|
|
304
|
-
return false if value == false
|
|
305
|
-
|
|
306
|
-
raise ConfigError, "config key #{key_path || key} must be boolean"
|
|
307
|
-
end
|
|
308
328
|
|
|
309
329
|
def safe_expand_path( path )
|
|
310
330
|
return path unless path.start_with?( "~" )
|
|
@@ -314,6 +334,23 @@ module Carson
|
|
|
314
334
|
path
|
|
315
335
|
end
|
|
316
336
|
|
|
337
|
+
# Resolves Carson-owned runtime paths even when HOME is intentionally invalid.
|
|
338
|
+
# CI smoke uses that condition to verify TMPDIR and /tmp fallbacks.
|
|
339
|
+
def resolve_runtime_path( path:, fallback_leaf: )
|
|
340
|
+
expanded = safe_expand_path( path )
|
|
341
|
+
return expanded unless expanded.start_with?( "~" )
|
|
342
|
+
|
|
343
|
+
File.join( runtime_fallback_root, fallback_leaf )
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Shared fallback root for Carson-owned artefacts when HOME cannot be expanded.
|
|
347
|
+
def runtime_fallback_root
|
|
348
|
+
tmpdir = ENV.fetch( "TMPDIR", "" ).to_s.strip
|
|
349
|
+
return File.join( tmpdir, "carson" ) if tmpdir.start_with?( "/" )
|
|
350
|
+
|
|
351
|
+
"/tmp/carson"
|
|
352
|
+
end
|
|
353
|
+
|
|
317
354
|
# Returns an expanded path string, or nil when the value is absent/blank.
|
|
318
355
|
def fetch_optional_path( hash:, key: )
|
|
319
356
|
value = hash[ key ]
|
|
@@ -323,18 +360,32 @@ module Carson
|
|
|
323
360
|
safe_expand_path( text )
|
|
324
361
|
end
|
|
325
362
|
|
|
326
|
-
# Discovers files in the canonical directory and appends them to managed_files.
|
|
327
|
-
#
|
|
363
|
+
# Discovers files in the canonical lint-policy directory and appends them to managed_files.
|
|
364
|
+
# Explicit GitHub paths stay under .github/; flat policy files default to .github/linters/.
|
|
328
365
|
def resolve_canonical_files!
|
|
329
|
-
return if @
|
|
330
|
-
return unless Dir.exist?( @
|
|
366
|
+
return if @lint_canonical.nil? || @lint_canonical.empty?
|
|
367
|
+
return unless Dir.exist?( @lint_canonical )
|
|
331
368
|
|
|
332
|
-
Dir.glob( File.join( @
|
|
369
|
+
Dir.glob( File.join( @lint_canonical, "**", "*" ), File::FNM_DOTMATCH ).sort.each do |absolute_path|
|
|
370
|
+
basename = File.basename( absolute_path )
|
|
371
|
+
next if basename == "." || basename == ".."
|
|
333
372
|
next unless File.file?( absolute_path )
|
|
334
|
-
relative = absolute_path.delete_prefix( "#{@
|
|
335
|
-
managed_path =
|
|
373
|
+
relative = absolute_path.delete_prefix( "#{@lint_canonical}/" )
|
|
374
|
+
managed_path = canonical_managed_path( relative_path: relative )
|
|
336
375
|
@template_managed_files << managed_path unless @template_managed_files.include?( managed_path )
|
|
337
376
|
end
|
|
338
377
|
end
|
|
378
|
+
|
|
379
|
+
def canonical_managed_path( relative_path: )
|
|
380
|
+
raw_relative = relative_path.to_s
|
|
381
|
+
return ".github/#{raw_relative.delete_prefix( '.github/' )}" if raw_relative.start_with?( ".github/" )
|
|
382
|
+
|
|
383
|
+
clean_relative = raw_relative
|
|
384
|
+
first_segment = clean_relative.split( "/", 2 ).first
|
|
385
|
+
return ".github/#{clean_relative}" if CANONICAL_GITHUB_DIRECTORIES.include?( first_segment )
|
|
386
|
+
return ".github/#{clean_relative}" if !clean_relative.include?( "/" ) && CANONICAL_GITHUB_ROOT_FILES.include?( clean_relative )
|
|
387
|
+
|
|
388
|
+
".github/linters/#{clean_relative}"
|
|
389
|
+
end
|
|
339
390
|
end
|
|
340
391
|
end
|