carson 2.15.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1aaa71b00dc2ea0269042f7389a9b2d3fda1cf3d91b77832d3555dfd2b25f680
4
- data.tar.gz: 657626d6700eb16f8640c753e4b7d1e7dddd37d358bdfa6df5bd1e2dd3400d79
3
+ metadata.gz: 0c0c99d97f1939fc304d067069e80b38319192a67c738ecfb2358d78e22bc5c6
4
+ data.tar.gz: 2233f42b74551f7cea4445130c4a5acd06f47839bc27288673f9c60792c9659d
5
5
  SHA512:
6
- metadata.gz: 74e9840537605c3e3a75c5b6ad26eb76f06e9f8d9b2cdf564a5b7034fec10187a02e07b51c653a56351eaa4a3f4e58c99eef20a7f876fb75af854247521f829d
7
- data.tar.gz: 2f79700321e7e920695116f9780f6a64fa7259bc147e9d57e85bc472dfd5adc25b0ab48eefd38b9e5c7863423ac1b6d224fa7d6c56c887f9db6c8ca856d03c14
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 and detach Carson hooks path where applicable. |
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 and unsets `core.hooksPath` when it points to Carson-managed global hooks.
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,21 @@ 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
+
8
23
  ## 2.15.4 — Lint Workflow Fix
9
24
 
10
25
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.15.4
1
+ 2.16.0
@@ -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
- puts_line "#{repo_name}: #{label}"
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})"
@@ -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 )
@@ -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
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: 2.15.4
4
+ version: 2.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang