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
AGENT_WORKTREE_DIRS =

Agent directory names whose worktrees Carson may sweep.

%w[ .claude .codex ].freeze

Instance Method Summary collapse

Instance Method Details

#offboard!Object

Removes Carson-managed repository integration so a host repository can retire Carson cleanly.



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/carson/runtime/local/onboard.rb', line 195

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.



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/onboard.rb', line 10

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
40
41
42
43
44
45
# 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
			output.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 )

	# Clean stale worktree entries whose directories no longer exist.
	# Unblocks branch deletion for branches held by dead worktrees.
	git_run( "worktree", "prune" )

	active_branch = current_branch
	cwd_branch = cwd_worktree_branch
	counters = { deleted: 0, skipped: 0 }
	branches = []

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

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

	absorbed_branches = absorbed_local_branches( active_branch: active_branch, cwd_branch: cwd_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



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

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)"
			record_batch_skip( command: "prune", repo_path: repo_path, reason: "path not found" )
			failed += 1
			next
		end

		begin
			buffer = verbose? ? output : StringIO.new
			error_buffer = verbose? ? error : StringIO.new
			scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
			status = scoped_runtime.prune!
			unless verbose?
				summary = buffer.string.lines.last.to_s.strip
				puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
			end
			if status == EXIT_ERROR
				record_batch_skip( command: "prune", repo_path: repo_path, reason: "prune failed" )
				failed += 1
			else
				clear_batch_success( command: "prune", repo_path: repo_path )
				succeeded += 1
			end
		rescue StandardError => exception
			puts_line "#{repo_name}: FAIL (#{exception.message})"
			record_batch_skip( command: "prune", repo_path: repo_path, reason: exception.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.



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
82
83
84
85
# File 'lib/carson/runtime/local/onboard.rb', line 36

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 { it.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 { it.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. Checks each repo for safety (active worktrees, uncommitted changes) and marks unsafe repos as pending to avoid disrupting active work.



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
141
142
# File 'lib/carson/runtime/local/onboard.rb', line 90

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

	pending_before = pending_repos_for( command: "refresh" )
	if pending_before.any?
		puts_line "#{pending_before.length} repo#{plural_suffix( count: pending_before.length )} pending from previous run"
	end

	puts_line ""
	puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
	refreshed = 0
	pending = 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)"
			record_batch_skip( command: "refresh", repo_path: repo_path, reason: "path not found" )
			failed += 1
			next
		end

		safety = portfolio_repo_safety( repo_path: repo_path )
		unless safety.fetch( :safe )
			reason = safety.fetch( :reasons ).join( ", " )
			puts_line "#{repo_name}: PENDING (#{reason})"
			record_batch_skip( command: "refresh", repo_path: repo_path, reason: reason )
			pending += 1
			next
		end

		status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
		if status == EXIT_ERROR
			failed += 1
		else
			clear_batch_success( command: "refresh", repo_path: repo_path )
			refreshed += 1
		end
	end

	puts_line ""
	parts = [ "#{refreshed} refreshed" ]
	parts << "#{pending} still pending (will retry on next run)" if pending.positive?
	parts << "#{failed} failed" if failed.positive?
	puts_line "Refresh all complete: #{parts.join( ', ' )}."
	failed.zero? && pending.zero? ? EXIT_OK : EXIT_ERROR
end

#sweep_stale_worktrees!Object

Removes agent-owned worktrees whose branch content is already on main. Scans AGENT_WORKTREE_DIRS (e.g. .claude/worktrees/, .codex/worktrees/) under the main repo root. Safe: skips detached HEADs, the caller’s CWD, and dirty working trees (git worktree remove refuses without –force).



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/carson/runtime/local/worktree.rb', line 195

def sweep_stale_worktrees!
	main_root = main_worktree_root
	worktrees = worktree_list

	agent_prefixes = AGENT_WORKTREE_DIRS.filter_map do |dir|
		full = File.join( main_root, dir, "worktrees" )
		File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
	end
	return if agent_prefixes.empty?

	worktrees.each do |worktree|
		path = worktree.fetch( :path )
		branch = worktree.fetch( :branch, nil )
		next unless branch
		next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
		next if cwd_inside_worktree?( worktree_path: path )
		next if worktree_held_by_other_process?( worktree_path: path )
		next unless branch_absorbed_into_main?( branch: branch )

		# Remove the worktree (no --force: refuses if dirty working tree).
		_, _, rm_success, = git_run( "worktree", "remove", path )
		next unless rm_success

		puts_verbose "swept stale worktree: #{File.basename( path )} (branch: #{branch})"

		# Delete the local branch now that no worktree holds it.
		if !config.protected_branches.include?( branch )
			git_run( "branch", "-D", branch )
			puts_verbose "deleted branch: #{branch}"
		end
	end
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

#sync_all!Object

Syncs main branch across all governed repositories.



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
82
83
84
85
86
87
88
89
90
# File 'lib/carson/runtime/local/sync.rb', line 46

def sync_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 "Sync all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
	synced = 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)"
			record_batch_skip( command: "sync", repo_path: repo_path, reason: "path not found" )
			failed += 1
			next
		end

		begin
			scoped_runtime = build_scoped_runtime( repo_path: repo_path )
			status = scoped_runtime.sync!
			if status == EXIT_OK
				puts_line "#{repo_name}: ok" unless verbose?
				clear_batch_success( command: "sync", repo_path: repo_path )
				synced += 1
			else
				puts_line "#{repo_name}: FAIL" unless verbose?
				record_batch_skip( command: "sync", repo_path: repo_path, reason: "sync failed" )
				failed += 1
			end
		rescue StandardError => exception
			puts_line "#{repo_name}: FAIL (#{exception.message})"
			record_batch_skip( command: "sync", repo_path: repo_path, reason: exception.message )
			failed += 1
		end
	end

	puts_line ""
	puts_line "Sync all complete: #{synced} synced, #{failed} failed."
	failed.zero? ? EXIT_OK : EXIT_ERROR
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.



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
141
142
143
144
145
146
147
# File 'lib/carson/runtime/local/template.rb', line 96

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 { it.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 output of sync.



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

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 { it.fetch( :status ) == "drift" }
	error_count = results.count { it.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 { it.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

#template_check_all!Object

Read-only template drift check across all governed repositories.



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

def template_check_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 "Template check all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
	in_sync = 0
	drifted = 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)"
			record_batch_skip( command: "template_check", repo_path: repo_path, reason: "path not found" )
			failed += 1
			next
		end

		begin
			scoped_runtime = build_scoped_runtime( repo_path: repo_path )
			status = scoped_runtime.template_check!
			if status == EXIT_OK
				puts_line "#{repo_name}: in sync" unless verbose?
				clear_batch_success( command: "template_check", repo_path: repo_path )
				in_sync += 1
			else
				puts_line "#{repo_name}: DRIFT" unless verbose?
				drifted += 1
			end
		rescue StandardError => exception
			puts_line "#{repo_name}: FAIL (#{exception.message})"
			record_batch_skip( command: "template_check", repo_path: repo_path, reason: exception.message )
			failed += 1
		end
	end

	puts_line ""
	puts_line "Template check complete: #{in_sync} in sync, #{drifted} drifted, #{failed} failed."
	drifted.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
end

#worktree_create!(name:, json_output: false) ⇒ Object

Creates a new worktree under .claude/worktrees/<name> with a fresh branch. Uses main_worktree_root so this works even when called from inside a worktree.



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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/carson/runtime/local/worktree.rb', line 16

def worktree_create!( name:, json_output: false )
	worktrees_dir = File.join( main_worktree_root, ".claude", "worktrees" )
	worktree_path = File.join( worktrees_dir, name )

	if Dir.exist?( worktree_path )
		return worktree_finish(
			result: { command: "worktree create", status: "error", name: name, path: worktree_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

	# Sync main from remote before branching so the worktree starts
	# from the latest code. Prevents stale-base merge conflicts later.
	# Best-effort — if pull fails (non-ff, offline), continue anyway.
	main_root = main_worktree_root
	_, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", config.git_remote, base )
	puts_verbose pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}"

	# Ensure .claude/ is excluded from git status in the host repository.
	# Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
	ensure_claude_dir_excluded!

	# Create the worktree with a new branch based on the main branch.
	FileUtils.mkdir_p( worktrees_dir )
	_, worktree_stderr, worktree_success, = git_run( "worktree", "add", worktree_path, "-b", name, base )
	unless worktree_success
		error_text = worktree_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

	worktree_finish(
		result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: 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.



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

def worktree_remove!( worktree_path:, force: false, json_output: false )
	fingerprint_status = block_if_outsider_fingerprints!
	unless fingerprint_status.nil?
		if json_output
			output.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 )

	# Missing directory: worktree was destroyed externally (e.g. gh pr merge
	# --delete-branch). Clean up the stale git registration and delete the branch.
	if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
		return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
	end

	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 )
		safe_root = main_worktree_root
		return worktree_finish(
			result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
				error: "current working directory is inside this worktree",
				recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}" },
			exit_code: EXIT_BLOCK, json_output: json_output
		)
	end

	# Safety: refuse if another process has its CWD inside the worktree.
	# Protects against cross-process CWD crashes (e.g. an agent session
	# removed by a separate cleanup process while the agent's shell is inside).
	if worktree_held_by_other_process?( worktree_path: resolved_path )
		return worktree_finish(
			result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
				error: "another process has its working directory inside this worktree",
				recovery: "wait for the other session to finish, then retry" },
			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}"

	# Safety: refuse if the branch has unpushed commits (unless --force).
	# Prevents accidental destruction of work that exists only locally.
	unless force
		unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
		if unpushed
			return worktree_finish(
				result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
					branch: branch,
					error: unpushed[ :error ],
					recovery: unpushed[ :recovery ] },
				exit_code: EXIT_BLOCK, json_output: json_output
			)
		end
	end

	# 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