Module: Carson::Runtime::Housekeep

Included in:
Carson::Runtime
Defined in:
lib/carson/runtime/housekeep.rb

Instance Method Summary collapse

Instance Method Details

#housekeep!(json_output: false, dry_run: false) ⇒ Object

Serves the current repo: sync + prune.



13
14
15
16
17
# File 'lib/carson/runtime/housekeep.rb', line 13

def housekeep!( json_output: false, dry_run: false )
	return housekeep_one_dry_run if dry_run

	housekeep_one( repo_path: repo_root, json_output: json_output )
end

#housekeep_all!(json_output: false, dry_run: false) ⇒ Object

Knocks each governed repo’s gate in turn.



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
# File 'lib/carson/runtime/housekeep.rb', line 36

def housekeep_all!( json_output: false, dry_run: false )
	repos = config.govern_repos
	if repos.empty?
		result = { command: "housekeep", status: "error", error: "No governed repositories configured.", recovery: "Run carson onboard in each repo to register." }
		return housekeep_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
	end

	if dry_run
		repos.each_with_index do |repo_path, idx|
			puts_line "" if idx > 0
			unless Dir.exist?( repo_path )
				puts_line "#{File.basename( repo_path )}: SKIP (path not found)"
				next
			end
			scoped = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error, verbose: verbose? )
			scoped.housekeep_one_dry_run
		end
		total = repos.size
		puts_line ""
		puts_line "#{total} repo#{plural_suffix( count: total )} surveyed. Run without --dry-run to apply."
		return EXIT_OK
	end

	results = []
	repos.each do |repo_path|
		entry = housekeep_one_entry( repo_path: repo_path, silent: json_output )
		if entry[ :status ] == "ok"
			clear_batch_success( command: "housekeep", repo_path: repo_path )
		else
			record_batch_skip( command: "housekeep", repo_path: repo_path, reason: entry[ :error ] || "housekeep failed" )
		end
		results << entry
	end

	succeeded = results.count { |entry| entry[ :status ] == "ok" }
	failed = results.count { |entry| entry[ :status ] != "ok" }
	result = { command: "housekeep", status: failed.zero? ? "ok" : "partial", repos: results, succeeded: succeeded, failed: failed }
	housekeep_finish( result: result, exit_code: failed.zero? ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: results, succeeded: succeeded, failed: failed )
end

#housekeep_one_dry_runObject

Prints a dry-run plan for this repo without making any changes. Calls reap_dead_worktrees_plan and prune_plan on self (already scoped to the repo).



78
79
80
81
82
83
84
# File 'lib/carson/runtime/housekeep.rb', line 78

def housekeep_one_dry_run
	repo_name = File.basename( repo_root )
	worktree_plan = reap_dead_worktrees_plan
	branch_plan = prune_plan( dry_run: true )
	print_housekeep_dry_run( repo_name: repo_name, worktree_plan: worktree_plan, branch_plan: branch_plan )
	EXIT_OK
end

#housekeep_target!(target:, json_output: false, dry_run: false) ⇒ Object

Resolves a target name to a governed repo, then serves it.



20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/carson/runtime/housekeep.rb', line 20

def housekeep_target!( target:, json_output: false, dry_run: false )
	repo_path = resolve_governed_repo( target: target )
	unless repo_path
		result = { command: "housekeep", status: "error", error: "Not a governed repository: #{target}", recovery: "Run carson repos to see governed repositories." }
		return housekeep_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
	end

	if dry_run
		scoped = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error, verbose: verbose? )
		return scoped.housekeep_one_dry_run
	end

	housekeep_one( repo_path: repo_path, json_output: json_output )
end

#reap_dead_worktrees!Object

Removes dead worktrees — those whose content is on main, with merged PR evidence, or with closed-unmerged PR evidence and no open PR. Unblocks prune for the branches they hold. Three-layer dead check:

1. Content-absorbed: delegates to sweep_stale_worktrees! (shared, no gh needed).
2. Merged PR evidence: covers rebase/squash where main has since evolved
   the same files (requires gh).
3. Abandoned PR evidence: closed-but-unmerged PR on the exact branch tip,
   but only when no open PR still exists for the branch.


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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/carson/runtime/housekeep.rb', line 176

def reap_dead_worktrees!
	# Layer 1: sweep agent-owned worktrees whose content is on main.
	sweep_stale_worktrees!

	# Layers 2 and 3: PR evidence for remaining worktrees.
	return unless gh_available?

	main_root = main_worktree_root
	worktree_list.each do |worktree|
		next if worktree.path == main_root
		next unless worktree.branch
		next if worktree.holds_cwd?
		next if worktree.held_by_other_process?

		# Missing directory: worktree was destroyed externally.
		# Prune the stale entry and delete the branch immediately.
		unless Dir.exist?( worktree.path )
			git_run( "worktree", "prune" )
			puts_verbose "reaped stale worktree entry: #{File.basename( worktree.path )} (branch: #{worktree.branch})"
			if !config.protected_branches.include?( worktree.branch )
				git_run( "branch", "-D", worktree.branch )
				puts_verbose "deleted branch: #{worktree.branch}"
			end
			next
		end

		tip_sha = git_capture!( "rev-parse", "--verify", worktree.branch ).strip rescue nil
		next unless tip_sha

		merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
		if !merged_pr.nil?
			# Remove the worktree (no --force: refuses if dirty working tree).
			_, _, rm_success, = git_run( "worktree", "remove", worktree.path )
			next unless rm_success

			puts_verbose "reaped dead worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch})"

			# Delete the local branch now that no worktree holds it.
			if !config.protected_branches.include?( worktree.branch )
				git_run( "branch", "-D", worktree.branch )
				puts_verbose "deleted branch: #{worktree.branch}"
			end
			next
		end

		next if branch_has_open_pr?( branch: worktree.branch )

		abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
		next if abandoned_pr.nil?

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

		puts_verbose "reaped abandoned worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch}, closed PR: #{abandoned_pr.fetch( :url )})"

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

#reap_dead_worktrees_planObject

Returns a plan array describing what reap_dead_worktrees! would do for each non-main worktree, without executing any mutations. Each item: { name:, branch:, action: :reap|:skip, reason: }



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
# File 'lib/carson/runtime/housekeep.rb', line 89

def reap_dead_worktrees_plan
	main_root = main_worktree_root
	items = []

	agent_prefixes = Worktree::AGENT_DIRS.map do |dir|
		full = File.join( main_root, dir, "worktrees" )
		File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
	end.compact

	worktree_list.each do |worktree|
		next if worktree.path == main_root
		next unless worktree.branch

		item = { name: File.basename( worktree.path ), branch: worktree.branch }

		if worktree.holds_cwd?
			items << item.merge( action: :skip, reason: "held by current shell" )
			next
		end

		if worktree.held_by_other_process?
			items << item.merge( action: :skip, reason: "held by another process" )
			next
		end

		# Missing directory — would be reaped by worktree prune + branch delete.
		unless Dir.exist?( worktree.path )
			items << item.merge( action: :reap, reason: "directory missing (destroyed externally)" )
			next
		end

		# Layer 1: agent-owned + content absorbed into main (no gh needed).
		if agent_prefixes.any? { |prefix| worktree.path.start_with?( prefix ) } &&
				branch_absorbed_into_main?( branch: worktree.branch )
			items << item.merge( action: :reap, reason: "content absorbed into main" )
			next
		end

		# Layers 2 + 3: PR evidence — requires gh CLI.
		unless gh_available?
			items << item.merge( action: :skip, reason: "gh CLI not available for PR check" )
			next
		end

		tip_sha = begin
			git_capture!( "rev-parse", "--verify", worktree.branch ).strip
		rescue StandardError
			nil
		end

		unless tip_sha
			items << item.merge( action: :skip, reason: "cannot read branch tip SHA" )
			next
		end

		merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
		if merged_pr
			items << item.merge( action: :reap, reason: "merged #{pr_short_ref( merged_pr[ :url ] )}" )
			next
		end

		if branch_has_open_pr?( branch: worktree.branch )
			items << item.merge( action: :skip, reason: "open PR exists" )
			next
		end

		abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
		if abandoned_pr
			items << item.merge( action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr[ :url ] )}" )
			next
		end

		items << item.merge( action: :skip, reason: "no evidence to reap" )
	end

	items
end