Class: Carson::Worktree

Inherits:
Object
  • Object
show all
Defined in:
lib/carson/worktree.rb

Constant Summary collapse

AGENT_DIRS =

Agent directory names whose worktrees Carson may sweep.

%w[ .claude .codex ].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path:, branch:, runtime: nil) ⇒ Worktree

Returns a new instance of Worktree.



18
19
20
21
22
# File 'lib/carson/worktree.rb', line 18

def initialize( path:, branch:, runtime: nil )
	@path = path
	@branch = branch
	@runtime = runtime
end

Instance Attribute Details

#branchObject (readonly)

Returns the value of attribute branch.



16
17
18
# File 'lib/carson/worktree.rb', line 16

def branch
  @branch
end

#pathObject (readonly)

Returns the value of attribute path.



16
17
18
# File 'lib/carson/worktree.rb', line 16

def path
  @path
end

Class Method Details

.create!(name:, runtime:, 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.



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

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

	if Dir.exist?( worktree_path )
		return 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: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
		)
	end

	# Determine the base branch (main branch from config).
	base = runtime.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 = runtime.main_worktree_root
	_, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", runtime.config.git_remote, base )
	runtime.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!( runtime: runtime )

	# Create the worktree with a new branch based on the main branch.
	FileUtils.mkdir_p( worktrees_dir )
	_, worktree_stderr, worktree_success, = runtime.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 finish(
			result: { command: "worktree create", status: "error", name: name,
				error: error_text },
			exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
		)
	end

	finish(
		result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
		exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
	)
end

.find(path:, runtime:) ⇒ Object

Finds the Worktree entry for a given path, or nil. Compares using realpath to handle symlink differences.



53
54
55
56
# File 'lib/carson/worktree.rb', line 53

def self.find( path:, runtime: )
	canonical = runtime.realpath_safe( path )
	list( runtime: runtime ).find { |worktree| worktree.path == canonical }
end

.list(runtime:) ⇒ Object

Parses ‘git worktree list –porcelain` into Worktree instances. Normalises paths with realpath so comparisons work across symlink differences.



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/carson/worktree.rb', line 28

def self.list( runtime: )
	raw = runtime.git_capture!( "worktree", "list", "--porcelain" )
	entries = []
	current_path = nil
	current_branch = :unset
	raw.lines.each do |line|
		line = line.strip
		if line.empty?
			entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
			current_path = nil
			current_branch = :unset
		elsif line.start_with?( "worktree " )
			current_path = runtime.realpath_safe( line.sub( "worktree ", "" ) )
		elsif line.start_with?( "branch " )
			current_branch = line.sub( "branch refs/heads/", "" )
		elsif line == "detached"
			current_branch = nil
		end
	end
	entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
	entries
end

.registered?(path:, runtime:) ⇒ Boolean

Returns true if the path is a registered git worktree.

Returns:

  • (Boolean)


59
60
61
62
# File 'lib/carson/worktree.rb', line 59

def self.registered?( path:, runtime: )
	canonical = runtime.realpath_safe( path )
	list( runtime: runtime ).any? { |worktree| worktree.path == canonical }
end

.remove!(path:, runtime:, 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 caller explicitly passes force: true via CLI –force flag.



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

def self.remove!( path:, runtime:, force: false, json_output: false )
	fingerprint_status = runtime.block_if_outsider_fingerprints!
	unless fingerprint_status.nil?
		if json_output
			runtime.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: Runtime::EXIT_BLOCK
			} )
		end
		return fingerprint_status
	end

	resolved_path = resolve_path( path: path, runtime: runtime )

	# 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 ) && registered?( path: resolved_path, runtime: runtime )
		return remove_missing!( resolved_path: resolved_path, runtime: runtime, json_output: json_output )
	end

	unless registered?( path: resolved_path, runtime: runtime )
		return 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: Runtime::EXIT_ERROR, runtime: runtime, 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.
	entry = find( path: resolved_path, runtime: runtime )
	if entry&.holds_cwd?
		safe_root = runtime.main_worktree_root
		return 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: Runtime::EXIT_BLOCK, runtime: runtime, 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 entry&.held_by_other_process?
		return 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: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
		)
	end

	branch = entry&.branch
	runtime.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, runtime: runtime )
		if unpushed
			return finish(
				result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
					branch: branch,
					error: unpushed[ :error ],
					recovery: unpushed[ :recovery ] },
				exit_code: Runtime::EXIT_BLOCK, runtime: runtime, 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_stderr, rm_success, = runtime.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 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: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
			)
		end
		return finish(
			result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
				error: error_text },
			exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
		)
	end
	runtime.puts_verbose "worktree_removed: #{resolved_path}"

	# Step 2: delete the local branch.
	branch_deleted = false
	if branch && !runtime.config.protected_branches.include?( branch )
		_, del_stderr, del_success, = runtime.git_run( "branch", "-D", branch )
		if del_success
			runtime.puts_verbose "branch_deleted: #{branch}"
			branch_deleted = true
		else
			runtime.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 && !runtime.config.protected_branches.include?( branch )
		remote_branch = branch
		_, _, rd_success, = runtime.git_run( "push", runtime.config.git_remote, "--delete", remote_branch )
		if rd_success
			runtime.puts_verbose "remote_branch_deleted: #{runtime.config.git_remote}/#{remote_branch}"
			remote_deleted = true
		end
	end
	finish(
		result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
			branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
		exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
	)
end

.sweep_stale!(runtime:) ⇒ Object

Removes agent-owned worktrees whose branch content is already on main. Scans AGENT_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).



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/carson/worktree.rb', line 246

def self.sweep_stale!( runtime: )
	main_root = runtime.main_worktree_root
	worktrees = list( runtime: runtime )

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

	worktrees.each do |worktree|
		next unless worktree.branch
		next unless agent_prefixes.any? { |prefix| worktree.path.start_with?( prefix ) }
		next if worktree.holds_cwd?
		next if worktree.held_by_other_process?
		next unless runtime.branch_absorbed_into_main?( branch: worktree.branch )

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

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

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

Instance Method Details

#held_by_other_process?Boolean

Does another process have its CWD inside this worktree?

Returns:

  • (Boolean)


290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/carson/worktree.rb', line 290

def held_by_other_process?
	canonical = realpath_safe( path )
	return false if canonical.nil? || canonical.empty?
	return false unless Dir.exist?( canonical )

	stdout, = Open3.capture3( "lsof", "-d", "cwd" )
	# Do NOT gate on exit status — lsof exits non-zero on macOS when SIP blocks
	# access to some system processes, even though user-process output is valid.
	return false if stdout.nil? || stdout.empty?

	normalised = File.join( canonical, "" )
	my_pid = Process.pid
	stdout.lines.drop( 1 ).any? do |line|
		fields = line.strip.split( /\s+/ )
		next false unless fields.length >= 9
		next false if fields[ 1 ].to_i == my_pid
		name = fields[ 8.. ].join( " " )
		name == canonical || name.start_with?( normalised )
	end
rescue Errno::ENOENT
	# lsof not installed.
	false
rescue StandardError
	false
end

#holds_cwd?Boolean

Is the current process CWD inside this worktree?

Returns:

  • (Boolean)


280
281
282
283
284
285
286
287
# File 'lib/carson/worktree.rb', line 280

def holds_cwd?
	cwd = realpath_safe( Dir.pwd )
	worktree = realpath_safe( path )
	normalised = File.join( worktree, "" )
	cwd == worktree || cwd.start_with?( normalised )
rescue StandardError
	false
end