carson 2.15.3 → 2.16.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 +2 -2
- data/MANUAL.md +10 -1
- data/RELEASE.md +25 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/local.rb +206 -1
- data/lib/carson/runtime/setup.rb +16 -0
- data/lib/carson/runtime.rb +3 -0
- data/templates/.github/workflows/carson-lint.yml +0 -1
- 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: 0c0c99d97f1939fc304d067069e80b38319192a67c738ecfb2358d78e22bc5c6
|
|
4
|
+
data.tar.gz: 2233f42b74551f7cea4445130c4a5acd06f47839bc27288673f9c60792c9659d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c03226c3e685b672675e6aee16e4027d3f4ffb992a9ad6894a971a6c079d0f0f6c749e0806605109a588805788aa73f6e1c23aa0cec5ab448ee7d48f1c5c63c5
|
|
7
|
+
data.tar.gz: c9587d82f0b6f75fe0146d74da495e229486a49d4fb39fc3b3a6e774391dbb6439f099f2f2474b2cf603a49c9eaf1ffeaaf416f2baf49f0ab303b6fcfd17c930
|
data/API.md
CHANGED
|
@@ -19,8 +19,8 @@ carson <command> [subcommand] [arguments]
|
|
|
19
19
|
| `carson lint policy --source <path-or-git-url> [--ref <git-ref>] [--force]` | Distribute lint configs from a central source into the governed repo's `.github/linters/`. |
|
|
20
20
|
| `carson onboard [repo_path]` | Apply one-command baseline setup for a target git repository. Auto-triggers `setup` on first run. |
|
|
21
21
|
| `carson prepare` | Install or refresh Carson-managed global hooks. |
|
|
22
|
-
| `carson refresh [repo_path]` | Re-apply hooks, templates, and audit after upgrading Carson. |
|
|
23
|
-
| `carson offboard [repo_path]` | Remove Carson-managed host artefacts
|
|
22
|
+
| `carson refresh [repo_path]` | Re-apply hooks, templates, and audit after upgrading Carson. Auto-propagates template updates to the remote via worktree (branch workflow: PR on `carson/template-sync`; trunk workflow: push to main). |
|
|
23
|
+
| `carson offboard [repo_path]` | Remove Carson-managed host artefacts, detach Carson hooks path, and deregister from `govern.repos`. |
|
|
24
24
|
|
|
25
25
|
### Daily commands
|
|
26
26
|
|
data/MANUAL.md
CHANGED
|
@@ -354,6 +354,15 @@ carson template check
|
|
|
354
354
|
- Run `carson refresh` to re-apply hooks and templates for the new Carson version.
|
|
355
355
|
- Run `carson refresh --all` to refresh all governed repositories at once.
|
|
356
356
|
|
|
357
|
+
**Template auto-propagation**
|
|
358
|
+
|
|
359
|
+
When `carson refresh` detects template drift, it applies the updates locally and then auto-propagates them to the remote:
|
|
360
|
+
|
|
361
|
+
- **Branch workflow** (default): creates a `carson/template-sync` branch, pushes updates, and opens (or updates) a PR. Re-running refresh force-pushes to the same branch — idempotent.
|
|
362
|
+
- **Trunk workflow**: pushes template changes directly to main.
|
|
363
|
+
|
|
364
|
+
Propagation uses a temporary git worktree so the user's working tree and current branch are never disturbed. If propagation fails (no remote, push denied), the local apply still succeeds — propagation errors are reported but non-blocking.
|
|
365
|
+
|
|
357
366
|
## Offboard a Repository
|
|
358
367
|
|
|
359
368
|
To retire Carson from a repository:
|
|
@@ -362,7 +371,7 @@ To retire Carson from a repository:
|
|
|
362
371
|
carson offboard /path/to/your-repo
|
|
363
372
|
```
|
|
364
373
|
|
|
365
|
-
This removes Carson-managed host artefacts
|
|
374
|
+
This removes Carson-managed host artefacts, unsets `core.hooksPath` when it points to Carson-managed global hooks, and deregisters the repository from `govern.repos` so `carson govern` and `carson refresh --all` no longer target it.
|
|
366
375
|
|
|
367
376
|
## Related Documents
|
|
368
377
|
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,31 @@ 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.16.0 — Auto-propagate Template Changes
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- `carson refresh` now auto-propagates template updates to the remote. When template drift is detected and applied locally, Carson creates a git worktree, writes the updated templates, commits, and pushes — no manual git workflow required.
|
|
13
|
+
- **Branch workflow** (default): pushes to `carson/template-sync` and creates (or updates) a PR. Re-running refresh force-pushes updates to the same branch.
|
|
14
|
+
- **Trunk workflow**: pushes template changes directly to main.
|
|
15
|
+
- The worktree approach ensures zero disturbance to the user's working tree and current branch.
|
|
16
|
+
- `carson refresh --all` now surfaces PR URLs and push refs in the per-repo summary line.
|
|
17
|
+
- New public `template_sync_result` accessor on `Runtime` for cross-module access to propagation outcomes.
|
|
18
|
+
|
|
19
|
+
### Migration
|
|
20
|
+
|
|
21
|
+
No configuration changes needed. Run `carson refresh` — template updates are now automatically pushed upstream.
|
|
22
|
+
|
|
23
|
+
## 2.15.4 — Lint Workflow Fix
|
|
24
|
+
|
|
25
|
+
### What changed
|
|
26
|
+
|
|
27
|
+
- Removed explicit `LINTER_RULES_PATH: .github/linters` from the Carson Lint workflow template. MegaLinter v8 crashes with `ValueError` when the directory does not exist. The path is already MegaLinter's default — omitting it lets MegaLinter use `.github/linters/` when present and silently skip when absent.
|
|
28
|
+
|
|
29
|
+
### Migration
|
|
30
|
+
|
|
31
|
+
Run `carson refresh` in governed repositories to pick up the updated workflow.
|
|
32
|
+
|
|
8
33
|
## 2.15.3 — Initial Commit Guard
|
|
9
34
|
|
|
10
35
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.16.0
|
data/lib/carson/runtime/local.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module Carson
|
|
2
2
|
class Runtime
|
|
3
3
|
module Local
|
|
4
|
+
TEMPLATE_SYNC_BRANCH = "carson/template-sync".freeze
|
|
5
|
+
|
|
4
6
|
def sync!
|
|
5
7
|
fingerprint_status = block_if_outsider_fingerprints!
|
|
6
8
|
return fingerprint_status unless fingerprint_status.nil?
|
|
@@ -216,9 +218,12 @@ module Carson
|
|
|
216
218
|
hook_status = prepare!
|
|
217
219
|
return hook_status unless hook_status == EXIT_OK
|
|
218
220
|
|
|
221
|
+
drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
|
|
219
222
|
template_status = template_apply!
|
|
220
223
|
return template_status unless template_status == EXIT_OK
|
|
221
224
|
|
|
225
|
+
@template_sync_result = template_propagate!( drift_count: drift_count )
|
|
226
|
+
|
|
222
227
|
audit_status = audit!
|
|
223
228
|
if audit_status == EXIT_OK
|
|
224
229
|
puts_line "OK: Carson refresh completed for #{repo_root}."
|
|
@@ -242,6 +247,8 @@ module Carson
|
|
|
242
247
|
puts_line "Templates in sync."
|
|
243
248
|
end
|
|
244
249
|
|
|
250
|
+
@template_sync_result = template_propagate!( drift_count: template_drift_count )
|
|
251
|
+
|
|
245
252
|
audit_status = audit!
|
|
246
253
|
puts_line "Refresh complete."
|
|
247
254
|
audit_status
|
|
@@ -307,6 +314,8 @@ module Carson
|
|
|
307
314
|
end
|
|
308
315
|
end
|
|
309
316
|
remove_empty_offboard_directories!
|
|
317
|
+
remove_govern_repo!( repo_path: File.expand_path( repo_root ) )
|
|
318
|
+
puts_verbose "govern_deregistered: #{File.expand_path( repo_root )}"
|
|
310
319
|
puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
|
|
311
320
|
if verbose?
|
|
312
321
|
puts_line "OK: Carson offboard completed for #{repo_root}."
|
|
@@ -422,6 +431,201 @@ module Carson
|
|
|
422
431
|
|
|
423
432
|
private
|
|
424
433
|
|
|
434
|
+
# Orchestrates worktree-based template propagation to the remote.
|
|
435
|
+
# Skips silently when there is no drift or no remote configured.
|
|
436
|
+
# Returns a result hash stored in @template_sync_result.
|
|
437
|
+
def template_propagate!( drift_count: )
|
|
438
|
+
if drift_count.zero?
|
|
439
|
+
puts_verbose "template_propagate: skip (no drift)"
|
|
440
|
+
return { status: :skip, reason: "no drift" }
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
unless git_remote_exists?( remote_name: config.git_remote )
|
|
444
|
+
puts_verbose "template_propagate: skip (no remote #{config.git_remote})"
|
|
445
|
+
return { status: :skip, reason: "no remote" }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
worktree_dir = nil
|
|
449
|
+
begin
|
|
450
|
+
worktree_dir = template_propagate_create_worktree!
|
|
451
|
+
template_propagate_write_files!( worktree_dir: worktree_dir )
|
|
452
|
+
committed = template_propagate_commit!( worktree_dir: worktree_dir )
|
|
453
|
+
unless committed
|
|
454
|
+
puts_verbose "template_propagate: skip (no changes after write)"
|
|
455
|
+
return { status: :skip, reason: "no changes" }
|
|
456
|
+
end
|
|
457
|
+
result = template_propagate_deliver!( worktree_dir: worktree_dir )
|
|
458
|
+
template_propagate_report!( result: result )
|
|
459
|
+
result
|
|
460
|
+
rescue StandardError => e
|
|
461
|
+
puts_verbose "template_propagate: error (#{e.message})"
|
|
462
|
+
{ status: :error, reason: e.message }
|
|
463
|
+
ensure
|
|
464
|
+
template_propagate_cleanup!( worktree_dir: worktree_dir ) if worktree_dir
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Creates a detached worktree from the remote main, checks out the sync branch,
|
|
469
|
+
# and disables hooks so Carson's own pre-commit never fires inside the worktree.
|
|
470
|
+
def template_propagate_create_worktree!
|
|
471
|
+
worktree_dir = File.join( Dir.tmpdir, "carson-template-sync-#{Process.pid}-#{Time.now.to_i}" )
|
|
472
|
+
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
473
|
+
|
|
474
|
+
git_system!( "fetch", config.git_remote, config.main_branch )
|
|
475
|
+
git_system!( "worktree", "add", "--detach", worktree_dir, "#{config.git_remote}/#{config.main_branch}" )
|
|
476
|
+
wt_git.run( "checkout", "-B", TEMPLATE_SYNC_BRANCH )
|
|
477
|
+
wt_git.run( "config", "core.hooksPath", "/dev/null" )
|
|
478
|
+
puts_verbose "template_propagate: worktree created at #{worktree_dir}"
|
|
479
|
+
worktree_dir
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Copies all Carson template source files into the worktree.
|
|
483
|
+
# Also removes superseded files present in the worktree.
|
|
484
|
+
def template_propagate_write_files!( worktree_dir: )
|
|
485
|
+
config.template_managed_files.each do |managed_file|
|
|
486
|
+
relative_within_github = managed_file.delete_prefix( ".github/" )
|
|
487
|
+
template_path = File.join( github_templates_dir, relative_within_github )
|
|
488
|
+
template_path = File.join( github_templates_dir, File.basename( managed_file ) ) unless File.file?( template_path )
|
|
489
|
+
next unless File.file?( template_path )
|
|
490
|
+
|
|
491
|
+
target_path = File.join( worktree_dir, managed_file )
|
|
492
|
+
FileUtils.mkdir_p( File.dirname( target_path ) )
|
|
493
|
+
expected_content = normalize_text( text: File.read( template_path ) )
|
|
494
|
+
File.write( target_path, expected_content )
|
|
495
|
+
puts_verbose "template_propagate: wrote #{managed_file}"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
template_superseded_present_in( root: worktree_dir ).each do |file|
|
|
499
|
+
file_path = File.join( worktree_dir, file )
|
|
500
|
+
File.delete( file_path )
|
|
501
|
+
puts_verbose "template_propagate: removed superseded #{file}"
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Stages all changes in the worktree and commits if there is an actual diff.
|
|
506
|
+
# Returns true if a commit was created, false if worktree content matches remote.
|
|
507
|
+
def template_propagate_commit!( worktree_dir: )
|
|
508
|
+
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
509
|
+
wt_git.run( "add", "--all" )
|
|
510
|
+
|
|
511
|
+
_, _, no_diff, = wt_git.run( "diff", "--cached", "--quiet" )
|
|
512
|
+
return false if no_diff
|
|
513
|
+
|
|
514
|
+
wt_git.run( "commit", "-m", "chore: sync Carson #{Carson::VERSION} managed templates" )
|
|
515
|
+
puts_verbose "template_propagate: committed"
|
|
516
|
+
true
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Dispatches to trunk or branch delivery based on workflow style.
|
|
520
|
+
def template_propagate_deliver!( worktree_dir: )
|
|
521
|
+
if config.workflow_style == "trunk"
|
|
522
|
+
template_propagate_deliver_trunk!( worktree_dir: worktree_dir )
|
|
523
|
+
else
|
|
524
|
+
template_propagate_deliver_branch!( worktree_dir: worktree_dir )
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Trunk mode: push template changes directly to main.
|
|
529
|
+
def template_propagate_deliver_trunk!( worktree_dir: )
|
|
530
|
+
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
531
|
+
stdout_text, stderr_text, success, = wt_git.run( "push", config.git_remote, "HEAD:refs/heads/#{config.main_branch}" )
|
|
532
|
+
unless success
|
|
533
|
+
error_text = stderr_text.to_s.strip
|
|
534
|
+
error_text = "push to #{config.main_branch} failed" if error_text.empty?
|
|
535
|
+
raise error_text
|
|
536
|
+
end
|
|
537
|
+
puts_verbose "template_propagate: pushed to #{config.main_branch}"
|
|
538
|
+
{ status: :pushed, ref: config.main_branch }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Branch mode: force-push the sync branch and ensure a PR exists.
|
|
542
|
+
def template_propagate_deliver_branch!( worktree_dir: )
|
|
543
|
+
wt_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
544
|
+
stdout_text, stderr_text, success, = wt_git.run( "push", "--force-with-lease", config.git_remote, "#{TEMPLATE_SYNC_BRANCH}:#{TEMPLATE_SYNC_BRANCH}" )
|
|
545
|
+
unless success
|
|
546
|
+
error_text = stderr_text.to_s.strip
|
|
547
|
+
error_text = "push #{TEMPLATE_SYNC_BRANCH} failed" if error_text.empty?
|
|
548
|
+
raise error_text
|
|
549
|
+
end
|
|
550
|
+
puts_verbose "template_propagate: pushed #{TEMPLATE_SYNC_BRANCH}"
|
|
551
|
+
|
|
552
|
+
pr_url = template_propagate_ensure_pr!( worktree_dir: worktree_dir )
|
|
553
|
+
{ status: :pr, branch: TEMPLATE_SYNC_BRANCH, pr_url: pr_url }
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Checks for an existing open PR from the sync branch; creates one if none exists.
|
|
557
|
+
# Returns the PR URL.
|
|
558
|
+
def template_propagate_ensure_pr!( worktree_dir: )
|
|
559
|
+
wt_gh = Adapters::GitHub.new( repo_root: worktree_dir )
|
|
560
|
+
|
|
561
|
+
# Check for existing open PR.
|
|
562
|
+
stdout_text, _, success, = wt_gh.run(
|
|
563
|
+
"pr", "list",
|
|
564
|
+
"--head", TEMPLATE_SYNC_BRANCH,
|
|
565
|
+
"--base", config.main_branch,
|
|
566
|
+
"--state", "open",
|
|
567
|
+
"--json", "url",
|
|
568
|
+
"--jq", ".[0].url"
|
|
569
|
+
)
|
|
570
|
+
existing_url = stdout_text.to_s.strip
|
|
571
|
+
if success && !existing_url.empty?
|
|
572
|
+
puts_verbose "template_propagate: existing PR #{existing_url}"
|
|
573
|
+
return existing_url
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Create new PR.
|
|
577
|
+
stdout_text, stderr_text, success, = wt_gh.run(
|
|
578
|
+
"pr", "create",
|
|
579
|
+
"--head", TEMPLATE_SYNC_BRANCH,
|
|
580
|
+
"--base", config.main_branch,
|
|
581
|
+
"--title", "chore: sync Carson #{Carson::VERSION} managed templates",
|
|
582
|
+
"--body", "Auto-generated by `carson refresh`.\n\nUpdates managed template files to match Carson #{Carson::VERSION}."
|
|
583
|
+
)
|
|
584
|
+
unless success
|
|
585
|
+
error_text = stderr_text.to_s.strip
|
|
586
|
+
error_text = "gh pr create failed" if error_text.empty?
|
|
587
|
+
raise error_text
|
|
588
|
+
end
|
|
589
|
+
pr_url = stdout_text.to_s.strip
|
|
590
|
+
puts_verbose "template_propagate: created PR #{pr_url}"
|
|
591
|
+
pr_url
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Removes the worktree regardless of state.
|
|
595
|
+
def template_propagate_cleanup!( worktree_dir: )
|
|
596
|
+
git_run( "worktree", "remove", "--force", worktree_dir )
|
|
597
|
+
puts_verbose "template_propagate: worktree cleaned up"
|
|
598
|
+
rescue StandardError => e
|
|
599
|
+
puts_verbose "template_propagate: cleanup warning (#{e.message})"
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Prints a human-readable summary of the propagation result.
|
|
603
|
+
def template_propagate_report!( result: )
|
|
604
|
+
case result.fetch( :status )
|
|
605
|
+
when :pushed
|
|
606
|
+
puts_line "Templates pushed to #{result.fetch( :ref )}."
|
|
607
|
+
when :pr
|
|
608
|
+
puts_line "Template sync PR: #{result.fetch( :pr_url )}"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Checks which superseded files exist under an arbitrary root directory.
|
|
613
|
+
def template_superseded_present_in( root: )
|
|
614
|
+
config.template_superseded_files.select do |file|
|
|
615
|
+
File.file?( File.join( root, file ) )
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def refresh_sync_suffix( result: )
|
|
620
|
+
return "" if result.nil?
|
|
621
|
+
|
|
622
|
+
case result.fetch( :status )
|
|
623
|
+
when :pushed then " (templates pushed to #{result.fetch( :ref )})"
|
|
624
|
+
when :pr then " (PR: #{result.fetch( :pr_url )})"
|
|
625
|
+
else ""
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
425
629
|
# Refreshes a single governed repository using a scoped Runtime.
|
|
426
630
|
def refresh_single_repo( repo_path:, repo_name: )
|
|
427
631
|
if verbose?
|
|
@@ -431,7 +635,8 @@ module Carson
|
|
|
431
635
|
end
|
|
432
636
|
status = rt.refresh!
|
|
433
637
|
label = refresh_status_label( status: status )
|
|
434
|
-
|
|
638
|
+
sync_suffix = refresh_sync_suffix( result: rt.template_sync_result )
|
|
639
|
+
puts_line "#{repo_name}: #{label}#{sync_suffix}"
|
|
435
640
|
status
|
|
436
641
|
rescue StandardError => e
|
|
437
642
|
puts_line "#{repo_name}: FAIL (#{e.message})"
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -376,6 +376,22 @@ module Carson
|
|
|
376
376
|
input.start_with?( "y" )
|
|
377
377
|
end
|
|
378
378
|
|
|
379
|
+
# Removes a repo path from govern.repos in global config.
|
|
380
|
+
def remove_govern_repo!( repo_path: )
|
|
381
|
+
config_path = Config.global_config_path( repo_root: repo_root )
|
|
382
|
+
return if config_path.empty? || !File.file?( config_path )
|
|
383
|
+
|
|
384
|
+
existing_data = load_existing_config( path: config_path )
|
|
385
|
+
repos = Array( existing_data.dig( "govern", "repos" ) )
|
|
386
|
+
updated = repos.reject { |entry| File.expand_path( entry ) == File.expand_path( repo_path ) }
|
|
387
|
+
return if updated.length == repos.length
|
|
388
|
+
|
|
389
|
+
existing_data[ "govern" ] ||= {}
|
|
390
|
+
existing_data[ "govern" ][ "repos" ] = updated
|
|
391
|
+
File.write( config_path, JSON.pretty_generate( existing_data ) )
|
|
392
|
+
reload_config_after_setup!
|
|
393
|
+
end
|
|
394
|
+
|
|
379
395
|
# Appends a repo path to govern.repos without replacing the array via deep_merge.
|
|
380
396
|
def append_govern_repo!( repo_path: )
|
|
381
397
|
config_path = Config.global_config_path( repo_root: repo_root )
|
data/lib/carson/runtime.rb
CHANGED
|
@@ -33,8 +33,11 @@ module Carson
|
|
|
33
33
|
@config = Config.load( repo_root: repo_root )
|
|
34
34
|
@git_adapter = Adapters::Git.new( repo_root: repo_root )
|
|
35
35
|
@github_adapter = Adapters::GitHub.new( repo_root: repo_root )
|
|
36
|
+
@template_sync_result = nil
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
attr_reader :template_sync_result
|
|
40
|
+
|
|
38
41
|
private
|
|
39
42
|
|
|
40
43
|
attr_reader :repo_root, :tool_root, :out, :err, :in, :config, :git_adapter, :github_adapter
|