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

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.expand_path( repo_root ) )
	puts_verbose "govern_deregistered: #{File.expand_path( 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!Object

Removes stale local branches (gone upstream), orphan branches (no tracking) with merged PR evidence, and absorbed branches (content already on main, no open PR).



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
# File 'lib/carson/runtime/local/prune.rb', line 6

def prune!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	git_system!( "fetch", config.git_remote, "--prune" )
	active_branch = current_branch
	counters = { deleted: 0, skipped: 0 }

	stale_branches = stale_local_branches
	prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters )

	orphan_branches = orphan_local_branches( active_branch: active_branch )
	prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )

	absorbed_branches = absorbed_local_branches( active_branch: active_branch )
	prune_absorbed_branch_entries( absorbed_branches: absorbed_branches, counters: counters )

	return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?

	puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
	unless verbose?
		deleted_count = counters.fetch( :deleted )
		skipped_count = counters.fetch( :skipped )
		message = if deleted_count > 0 && skipped_count > 0
			"Pruned #{deleted_count}, skipped #{skipped_count} (--verbose for details)."
		elsif deleted_count > 0
			"Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
		else
			"Skipped #{skipped_count} branch#{plural_suffix( count: skipped_count )} (--verbose for details)."
		end
		puts_line message
	end
	EXIT_OK
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.message})"
			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!Object



4
5
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
# File 'lib/carson/runtime/local/sync.rb', line 4

def sync!
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	unless working_tree_clean?
		puts_line "BLOCK: working tree is dirty; commit/stash first, then run carson sync."
		return EXIT_BLOCK
	end
	start_branch = current_branch
	switched = false
	git_system!( "fetch", config.git_remote, "--prune" )
	if start_branch != config.main_branch
		git_system!( "switch", config.main_branch )
		switched = true
	end
	git_system!( "pull", "--ff-only", config.git_remote, config.main_branch )
	ahead_count, behind_count, error_text = main_sync_counts
	if error_text
		puts_line "BLOCK: unable to verify main sync state (#{error_text})."
		return EXIT_BLOCK
	end
	if ahead_count.zero? && behind_count.zero?
		puts_line "OK: local #{config.main_branch} is now in sync with #{config.git_remote}/#{config.main_branch}."
		return EXIT_OK
	end
	puts_line "BLOCK: local #{config.main_branch} still diverges (ahead=#{ahead_count}, behind=#{behind_count})."
	EXIT_BLOCK
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:) ⇒ Object

Creates a new worktree under .claude/worktrees/<name> with a fresh branch.



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
# File 'lib/carson/runtime/local/worktree.rb', line 9

def worktree_create!( name: )
	worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
	wt_path = File.join( worktrees_dir, name )

	if Dir.exist?( wt_path )
		puts_line "ERROR: worktree already exists: #{name}"
		puts_line "  Path: #{wt_path}"
		return EXIT_ERROR
	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?
		puts_line "ERROR: #{error_text}"
		return EXIT_ERROR
	end

	puts_line "Worktree created: #{name}"
	puts_line "  Path: #{wt_path}"
	puts_line "  Branch: #{name}"
	EXIT_OK
end

#worktree_done!(name: nil) ⇒ Object

Marks a worktree as completed without deleting it. Verifies all changes are committed. Deferred deletion — cleanup happens later.



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
# File 'lib/carson/runtime/local/worktree.rb', line 40

def worktree_done!( name: nil )
	if name.to_s.strip.empty?
		# Try to detect current worktree from CWD.
		puts_line "ERROR: missing worktree name. Use: carson worktree done <name>"
		return EXIT_ERROR
	end

	resolved_path = resolve_worktree_path( worktree_path: name )

	unless worktree_registered?( path: resolved_path )
		puts_line "ERROR: #{name} is not a registered worktree."
		return EXIT_ERROR
	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?
		puts_line "Worktree has uncommitted changes: #{name}"
		puts_line "  Commit your changes first, then run `carson worktree done #{name}` again."
		return EXIT_BLOCK
	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
			puts_line "Worktree has unpushed commits: #{name}"
			puts_line "  Push with `git -C #{resolved_path} push #{remote} #{branch}` first."
			return EXIT_BLOCK
		end
	end

	puts_line "Worktree done: #{name}"
	puts_line "  Branch: #{branch || '(detached)'}"
	puts_line "  Cleanup later with `carson worktree remove #{name}` or `carson housekeep`."
	EXIT_OK
end

#worktree_remove!(worktree_path:, force: 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.



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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/carson/runtime/local/worktree.rb', line 84

def worktree_remove!( worktree_path:, force: false )
	fingerprint_status = block_if_outsider_fingerprints!
	return fingerprint_status unless fingerprint_status.nil?

	resolved_path = resolve_worktree_path( worktree_path: worktree_path )

	unless worktree_registered?( path: resolved_path )
		puts_line "ERROR: #{resolved_path} is not a registered worktree."
		puts_line "  Registered worktrees:"
		worktree_list.each { |wt| puts_line "  - #{wt.fetch( :path )} [#{wt.fetch( :branch )}]" }
		return EXIT_ERROR
	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).
	# Try safe removal first. Only use --force if the user explicitly requested it.
	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" ) )
			puts_line "Worktree has uncommitted changes: #{File.basename( resolved_path )}"
			puts_line "  Commit or discard changes first, or use --force to override."
		else
			puts_line "ERROR: #{error_text}"
		end
		return EXIT_ERROR
	end
	puts_verbose "worktree_removed: #{resolved_path}"

	# Step 2: delete the local branch.
	if branch && !config.protected_branches.include?( branch )
		_, del_stderr, del_success, = git_run( "branch", "-D", branch )
		if del_success
			puts_verbose "branch_deleted: #{branch}"
		else
			puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
		end
	end

	# Step 3: delete the remote branch (best-effort).
	if branch && !config.protected_branches.include?( branch )
		remote_branch = branch
		git_run( "push", config.git_remote, "--delete", remote_branch )
		puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
	end

	unless verbose?
		puts_line "Worktree removed: #{File.basename( resolved_path )}"
	end
	EXIT_OK
end