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.



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
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/carson/runtime/local/onboard.rb', line 174

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!(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
			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 )

	# 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



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

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



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

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

		safety = portfolio_repo_safety( repo_path: repo_path )
		unless safety.fetch( :safe )
			puts_line "#{repo_name}: SKIP (#{safety.fetch( :reasons ).join( ', ' )})"
			skipped += 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 ""
	parts = [ "#{refreshed} refreshed" ]
	parts << "#{skipped} skipped" if skipped.positive?
	parts << "#{failed} failed" if failed.positive?
	puts_line "Refresh all complete: #{parts.join( ', ' )}."
	failed.zero? && skipped.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).



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
208
209
210
211
212
# File 'lib/carson/runtime/local/worktree.rb', line 182

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 |wt|
		path = wt.fetch( :path )
		branch = wt.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 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
# 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)"
			failed += 1
			next
		end

		begin
			rt = build_scoped_runtime( repo_path: repo_path )
			status = rt.sync!
			if status == EXIT_OK
				puts_line "#{repo_name}: ok" unless verbose?
				synced += 1
			else
				puts_line "#{repo_name}: FAIL" unless verbose?
				failed += 1
			end
		rescue StandardError => e
			puts_line "#{repo_name}: FAIL (#{e.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.



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

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

#template_check_all!Object

Read-only template drift check across all governed repositories.



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

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)"
			failed += 1
			next
		end

		begin
			rt = build_scoped_runtime( repo_path: repo_path )
			status = rt.template_check!
			if status == EXIT_OK
				puts_line "#{repo_name}: in sync" unless verbose?
				in_sync += 1
			else
				puts_line "#{repo_name}: DRIFT" unless verbose?
				drifted += 1
			end
		rescue StandardError => e
			puts_line "#{repo_name}: FAIL (#{e.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.



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

def worktree_create!( name:, json_output: false )
	worktrees_dir = File.join( main_worktree_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

	# 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 )
	_, 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

	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_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.



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

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 )

	# 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

	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