Module: Carson::Runtime::Local
- Included in:
- Carson::Runtime
- Defined in:
- lib/carson/runtime/local/sync.rb,
lib/carson/runtime/local/hooks.rb,
lib/carson/runtime/local/prune.rb,
lib/carson/runtime/local/onboard.rb,
lib/carson/runtime/local/template.rb,
lib/carson/runtime/local/worktree.rb
Constant Summary collapse
- TEMPLATE_SYNC_BRANCH =
"carson/template-sync".freeze
- SUPERSEDED =
[ ".github/carson-instructions.md", ".github/workflows/carson-lint.yml", ".github/.mega-linter.yml" ].freeze
Instance Method Summary collapse
-
#offboard! ⇒ Object
Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
-
#onboard! ⇒ Object
One-command onboarding for new repositories: detect remote, install hooks, apply templates, and run initial audit.
- #prune!(json_output: false) ⇒ Object
- #prune_all! ⇒ Object
-
#refresh! ⇒ Object
Re-applies hooks, templates, and audit after upgrading Carson.
-
#refresh_all! ⇒ Object
Re-applies hooks, templates, and audit across all governed repositories.
- #sync!(json_output: false) ⇒ Object
-
#template_apply!(push_prep: false) ⇒ Object
Applies managed template files as full-file writes from Carson sources.
-
#template_check! ⇒ Object
Read-only template drift check; returns block when managed files are out of sync.
-
#worktree_create!(name:, json_output: false) ⇒ Object
Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
-
#worktree_done!(name: nil, json_output: false) ⇒ Object
Marks a worktree as completed without deleting it.
-
#worktree_remove!(worktree_path:, force: false, json_output: false) ⇒ Object
Removes a worktree: directory, git registration, and branch.
Instance Method Details
#offboard! ⇒ Object
Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/carson/runtime/local/onboard.rb', line 161 def offboard! puts_verbose "" puts_verbose "[Offboard]" unless inside_git_work_tree? puts_line "ERROR: #{repo_root} is not a git repository." return EXIT_ERROR end if self.in.respond_to?( :tty? ) && self.in.tty? puts_line "" puts_line "This will remove Carson hooks, managed .github/ files," puts_line "and deregister this repository from portfolio governance." puts_line "Continue?" unless prompt_yes_no( default: false ) puts_line "Offboard cancelled." return EXIT_OK end end hooks_status = disable_carson_hooks_path! return hooks_status unless hooks_status == EXIT_OK removed_count = 0 missing_count = 0 offboard_cleanup_targets.each do |relative| absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" ) if File.exist?( absolute ) FileUtils.rm_rf( absolute ) puts_verbose "removed_path: #{relative}" removed_count += 1 else puts_verbose "skip_missing_path: #{relative}" missing_count += 1 end end remove_empty_offboard_directories! remove_govern_repo!( repo_path: File.( repo_root ) ) puts_verbose "govern_deregistered: #{File.( repo_root )}" puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}" if verbose? puts_line "OK: Carson offboard completed for #{repo_root}." else puts_line "Removed #{removed_count} file#{plural_suffix( count: removed_count )}. Offboard complete." end puts_line "" puts_line "Next: commit the removals and push to finalise offboarding." EXIT_OK end |
#onboard! ⇒ Object
One-command onboarding for new repositories: detect remote, install hooks, apply templates, and run initial audit.
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# File 'lib/carson/runtime/local/onboard.rb', line 6 def onboard! fingerprint_status = block_if_outsider_fingerprints! return fingerprint_status unless fingerprint_status.nil? unless inside_git_work_tree? puts_line "ERROR: #{repo_root} is not a git repository." return EXIT_ERROR end repo_name = File.basename( repo_root ) puts_line "" puts_line "Onboarding #{repo_name}..." if !global_config_exists? || !git_remote_exists?( remote_name: config.git_remote ) if self.in.respond_to?( :tty? ) && self.in.tty? setup_status = setup! return setup_status unless setup_status == EXIT_OK else silent_setup! end end onboard_apply! end |
#prune!(json_output: false) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
# File 'lib/carson/runtime/local/prune.rb', line 7 def prune!( json_output: false ) fingerprint_status = block_if_outsider_fingerprints! unless fingerprint_status.nil? if json_output out.puts JSON.pretty_generate( { command: "prune", status: "block", error: "Carson-owned artefacts detected in host repository", recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry", exit_code: EXIT_BLOCK } ) end return fingerprint_status end prune_git!( "fetch", config.git_remote, "--prune", json_output: json_output ) active_branch = current_branch counters = { deleted: 0, skipped: 0 } branches = [] stale_branches = stale_local_branches prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters, branches: branches ) orphan_branches = orphan_local_branches( active_branch: active_branch ) prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters, branches: branches ) absorbed_branches = absorbed_local_branches( active_branch: active_branch ) prune_absorbed_branch_entries( absorbed_branches: absorbed_branches, counters: counters, branches: branches ) prune_finish( result: { command: "prune", status: "ok", branches: branches, deleted: counters.fetch( :deleted ), skipped: counters.fetch( :skipped ) }, exit_code: EXIT_OK, json_output: json_output, counters: counters ) end |
#prune_all! ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/carson/runtime/local/onboard.rb', line 118 def prune_all! repos = config.govern_repos if repos.empty? puts_line "No governed repositories configured." puts_line " Run carson onboard in each repo to register." return EXIT_ERROR end puts_line "" puts_line "Prune all (#{repos.length} repo#{plural_suffix( count: repos.length )})" succeeded = 0 failed = 0 repos.each do |repo_path| repo_name = File.basename( repo_path ) unless Dir.exist?( repo_path ) puts_line "#{repo_name}: FAIL (path not found)" failed += 1 next end begin buf = verbose? ? out : StringIO.new err_buf = verbose? ? err : StringIO.new rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? ) status = rt.prune! unless verbose? summary = buf.string.lines.last.to_s.strip puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}" end status == EXIT_ERROR ? ( failed += 1 ) : ( succeeded += 1 ) rescue StandardError => e puts_line "#{repo_name}: FAIL (#{e.})" failed += 1 end end puts_line "" puts_line "Prune all complete: #{succeeded} pruned, #{failed} failed." failed.zero? ? EXIT_OK : EXIT_ERROR end |
#refresh! ⇒ Object
Re-applies hooks, templates, and audit after upgrading Carson.
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/carson/runtime/local/onboard.rb', line 32 def refresh! fingerprint_status = block_if_outsider_fingerprints! return fingerprint_status unless fingerprint_status.nil? unless inside_git_work_tree? puts_line "ERROR: #{repo_root} is not a git repository." return EXIT_ERROR end if verbose? puts_verbose "" puts_verbose "[Refresh]" hook_status = prepare! return hook_status unless hook_status == EXIT_OK drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" } template_status = template_apply! return template_status unless template_status == EXIT_OK @template_sync_result = template_propagate!( drift_count: drift_count ) audit_status = audit! if audit_status == EXIT_OK puts_line "OK: Carson refresh completed for #{repo_root}." elsif audit_status == EXIT_BLOCK puts_line "BLOCK: Carson refresh completed with policy blocks; resolve and rerun carson audit." end return audit_status end puts_line "Refresh" hook_status = with_captured_output { prepare! } return hook_status unless hook_status == EXIT_OK puts_line "Hooks installed (#{config.managed_hooks.count} hooks)." template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" } template_status = with_captured_output { template_apply! } return template_status unless template_status == EXIT_OK if template_drift_count.positive? puts_line "Templates applied (#{template_drift_count} updated)." else puts_line "Templates in sync." end @template_sync_result = template_propagate!( drift_count: template_drift_count ) audit_status = audit! puts_line "Refresh complete." audit_status end |
#refresh_all! ⇒ Object
Re-applies hooks, templates, and audit across all governed repositories.
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/carson/runtime/local/onboard.rb', line 84 def refresh_all! repos = config.govern_repos if repos.empty? puts_line "No governed repositories configured." puts_line " Run carson onboard in each repo to register." return EXIT_ERROR end puts_line "" puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})" refreshed = 0 failed = 0 repos.each do |repo_path| repo_name = File.basename( repo_path ) unless Dir.exist?( repo_path ) puts_line "#{repo_name}: FAIL (path not found)" failed += 1 next end status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name ) if status == EXIT_ERROR failed += 1 else refreshed += 1 end end puts_line "" puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed." failed.zero? ? EXIT_OK : EXIT_ERROR end |
#sync!(json_output: false) ⇒ Object
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/carson/runtime/local/sync.rb', line 6 def sync!( json_output: false ) fingerprint_status = block_if_outsider_fingerprints! return fingerprint_status unless fingerprint_status.nil? unless working_tree_clean? return sync_finish( result: { command: "sync", status: "block", error: "working tree is dirty", recovery: "git add -A && git commit, then carson sync" }, exit_code: EXIT_BLOCK, json_output: json_output ) end start_branch = current_branch switched = false sync_git!( "fetch", config.git_remote, "--prune", json_output: json_output ) if start_branch != config.main_branch sync_git!( "switch", config.main_branch, json_output: json_output ) switched = true end sync_git!( "pull", "--ff-only", config.git_remote, config.main_branch, json_output: json_output ) ahead_count, behind_count, error_text = main_sync_counts if error_text return sync_finish( result: { command: "sync", status: "block", error: "unable to verify main sync state (#{error_text})" }, exit_code: EXIT_BLOCK, json_output: json_output ) end if ahead_count.zero? && behind_count.zero? return sync_finish( result: { command: "sync", status: "ok", ahead: 0, behind: 0, main_branch: config.main_branch, remote: config.git_remote }, exit_code: EXIT_OK, json_output: json_output ) end sync_finish( result: { command: "sync", status: "block", ahead: ahead_count, behind: behind_count, main_branch: config.main_branch, remote: config.git_remote, error: "local #{config.main_branch} still diverges" }, exit_code: EXIT_BLOCK, json_output: json_output ) ensure git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch ) end |
#template_apply!(push_prep: false) ⇒ Object
Applies managed template files as full-file writes from Carson sources. Also removes superseded files that are no longer part of the managed set.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/carson/runtime/local/template.rb', line 48 def template_apply!( push_prep: false ) fingerprint_status = block_if_outsider_fingerprints! return fingerprint_status unless fingerprint_status.nil? puts_verbose "" puts_verbose "[Template Sync Apply]" results = template_results stale = template_superseded_present applied = 0 results.each do |entry| if entry.fetch( :status ) == "error" puts_verbose "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}" next end file_path = File.join( repo_root, entry.fetch( :file ) ) if entry.fetch( :status ) == "ok" puts_verbose "template_file: #{entry.fetch( :file )} status=ok reason=in_sync" next end FileUtils.mkdir_p( File.dirname( file_path ) ) File.write( file_path, entry.fetch( :applied_content ) ) puts_verbose "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}" applied += 1 end removed = 0 stale.each do |file| file_path = resolve_repo_path!( relative_path: file, label: "superseded file #{file}" ) File.delete( file_path ) puts_verbose "template_file: #{file} status=removed reason=superseded" removed += 1 end error_count = results.count { |entry| entry.fetch( :status ) == "error" } puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}" unless verbose? if applied.positive? || removed.positive? summary_parts = [] summary_parts << "#{applied} updated" if applied.positive? summary_parts << "#{removed} removed" if removed.positive? puts_line "Templates applied (#{summary_parts.join( ", " )})." else puts_line "Templates in sync." end end return EXIT_ERROR if error_count.positive? return EXIT_BLOCK if push_prep && push_prep_commit! EXIT_OK end |
#template_check! ⇒ Object
Read-only template drift check; returns block when managed files are out of sync.
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/carson/runtime/local/template.rb', line 13 def template_check! fingerprint_status = block_if_outsider_fingerprints! return fingerprint_status unless fingerprint_status.nil? puts_verbose "" puts_verbose "[Template Sync Check]" results = template_results stale = template_superseded_present drift_count = results.count { |entry| entry.fetch( :status ) == "drift" } error_count = results.count { |entry| entry.fetch( :status ) == "error" } stale_count = stale.count results.each do |entry| puts_verbose "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}" end stale.each { |file| puts_verbose "template_file: #{file} status=stale reason=superseded" } puts_verbose "template_summary: total=#{results.count} drift=#{drift_count} stale=#{stale_count} error=#{error_count}" unless verbose? if ( drift_count + stale_count ).positive? summary_parts = [] summary_parts << "#{drift_count} of #{results.count} drifted" if drift_count.positive? summary_parts << "#{stale_count} stale" if stale_count.positive? puts_line "Templates: #{summary_parts.join( ", " )}" results.select { |entry| entry.fetch( :status ) == "drift" }.each { |entry| puts_line " #{entry.fetch( :file )}" } stale.each { |file| puts_line " #{file} — superseded" } else puts_line "Templates: #{results.count} files in sync" end end return EXIT_ERROR if error_count.positive? ( drift_count + stale_count ).positive? ? EXIT_BLOCK : EXIT_OK end |
#worktree_create!(name:, json_output: false) ⇒ Object
Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'lib/carson/runtime/local/worktree.rb', line 10 def worktree_create!( name:, json_output: false ) worktrees_dir = File.join( repo_root, ".claude", "worktrees" ) wt_path = File.join( worktrees_dir, name ) if Dir.exist?( wt_path ) return worktree_finish( result: { command: "worktree create", status: "error", name: name, path: wt_path, error: "worktree already exists: #{name}", recovery: "carson worktree remove #{name}, then retry" }, exit_code: EXIT_ERROR, json_output: json_output ) end # Determine the base branch (main branch from config). base = config.main_branch # Create the worktree with a new branch based on the main branch. FileUtils.mkdir_p( worktrees_dir ) _, wt_stderr, wt_success, = git_run( "worktree", "add", wt_path, "-b", name, base ) unless wt_success error_text = wt_stderr.to_s.strip error_text = "unable to create worktree" if error_text.empty? return worktree_finish( result: { command: "worktree create", status: "error", name: name, error: error_text }, exit_code: EXIT_ERROR, json_output: json_output ) end # Record active worktree in session state. update_session( worktree: { name: name, path: wt_path, branch: name } ) worktree_finish( result: { command: "worktree create", status: "ok", name: name, path: wt_path, branch: name }, exit_code: EXIT_OK, json_output: json_output ) end |
#worktree_done!(name: nil, json_output: false) ⇒ Object
Marks a worktree as completed without deleting it. Verifies all changes are committed. Deferred deletion — cleanup happens later.
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/carson/runtime/local/worktree.rb', line 50 def worktree_done!( name: nil, json_output: false ) if name.to_s.strip.empty? return worktree_finish( result: { command: "worktree done", status: "error", error: "missing worktree name", recovery: "carson worktree done <name>" }, exit_code: EXIT_ERROR, json_output: json_output ) end resolved_path = resolve_worktree_path( worktree_path: name ) unless worktree_registered?( path: resolved_path ) return worktree_finish( result: { command: "worktree done", status: "error", name: name, error: "#{name} is not a registered worktree", recovery: "git worktree list" }, exit_code: EXIT_ERROR, json_output: json_output ) end # Check for uncommitted changes in the worktree. wt_status, _, status_success, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path ) if status_success && !wt_status.strip.empty? return worktree_finish( result: { command: "worktree done", status: "block", name: name, error: "worktree has uncommitted changes", recovery: "git -C #{resolved_path} add -A && git -C #{resolved_path} commit, then carson worktree done #{name}" }, exit_code: EXIT_BLOCK, json_output: json_output ) end # Check for unpushed commits. branch = worktree_branch( path: resolved_path ) if branch remote = config.git_remote remote_ref = "#{remote}/#{branch}" ahead, _, ahead_ok, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path ) if ahead_ok && ahead.strip.to_i > 0 return worktree_finish( result: { command: "worktree done", status: "block", name: name, branch: branch, error: "worktree has unpushed commits", recovery: "git -C #{resolved_path} push #{remote} #{branch}" }, exit_code: EXIT_BLOCK, json_output: json_output ) end end # Clear worktree from session state. update_session( worktree: :clear ) worktree_finish( result: { command: "worktree done", status: "ok", name: name, branch: branch || "(detached)", next_step: "carson worktree remove #{name}" }, exit_code: EXIT_OK, json_output: json_output ) end |
#worktree_remove!(worktree_path:, force: false, json_output: false) ⇒ Object
Removes a worktree: directory, git registration, and branch. Never forces removal — if the worktree has uncommitted changes, refuses unless the user explicitly passes force: true via CLI –force flag.
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/carson/runtime/local/worktree.rb', line 111 def worktree_remove!( worktree_path:, force: false, json_output: false ) fingerprint_status = block_if_outsider_fingerprints! unless fingerprint_status.nil? if json_output out.puts JSON.pretty_generate( { command: "worktree remove", status: "block", error: "Carson-owned artefacts detected in host repository", recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry", exit_code: EXIT_BLOCK } ) end return fingerprint_status end resolved_path = resolve_worktree_path( worktree_path: worktree_path ) unless worktree_registered?( path: resolved_path ) return worktree_finish( result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ), error: "#{resolved_path} is not a registered worktree", recovery: "git worktree list" }, exit_code: EXIT_ERROR, json_output: json_output ) end # Safety: refuse if the caller's shell CWD is inside the worktree. # Removing a directory while a shell is inside it kills the shell permanently. if cwd_inside_worktree?( worktree_path: resolved_path ) return worktree_finish( result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ), error: "current working directory is inside this worktree", recovery: "cd #{repo_root} && carson worktree remove #{File.basename( resolved_path )}" }, exit_code: EXIT_BLOCK, json_output: json_output ) end branch = worktree_branch( path: resolved_path ) puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}" # Step 1: remove the worktree (directory + git registration). rm_args = [ "worktree", "remove" ] rm_args << "--force" if force rm_args << resolved_path rm_stdout, rm_stderr, rm_success, = git_run( *rm_args ) unless rm_success error_text = rm_stderr.to_s.strip error_text = "unable to remove worktree" if error_text.empty? if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) ) return worktree_finish( result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ), error: "worktree has uncommitted changes", recovery: "commit or discard changes first, or use --force to override" }, exit_code: EXIT_ERROR, json_output: json_output ) end return worktree_finish( result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ), error: error_text }, exit_code: EXIT_ERROR, json_output: json_output ) end puts_verbose "worktree_removed: #{resolved_path}" # Step 2: delete the local branch. branch_deleted = false if branch && !config.protected_branches.include?( branch ) _, del_stderr, del_success, = git_run( "branch", "-D", branch ) if del_success puts_verbose "branch_deleted: #{branch}" branch_deleted = true else puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}" end end # Step 3: delete the remote branch (best-effort). remote_deleted = false if branch && !config.protected_branches.include?( branch ) remote_branch = branch _, _, rd_success, = git_run( "push", config.git_remote, "--delete", remote_branch ) if rd_success puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}" remote_deleted = true end end worktree_finish( result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ), branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted }, exit_code: EXIT_OK, json_output: json_output ) end |