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, prunable_reason: nil) ⇒ Worktree

Returns a new instance of Worktree.



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

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

Instance Attribute Details

#branchObject (readonly)

Returns the value of attribute branch.



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

def branch
  @branch
end

#pathObject (readonly)

Returns the value of attribute path.



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

def path
  @path
end

#prunable_reasonObject (readonly)

Returns the value of attribute prunable_reason.



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

def prunable_reason
  @prunable_reason
end

Class Method Details

.branch_unpushed_issue(branch:, worktree_path:, runtime:) ⇒ Object

Checks whether a branch has unpushed commits that would be lost on removal. Content-aware: after squash/rebase merge, SHAs differ but tree content may match main. Compares content, not SHAs. Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.



564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
# File 'lib/carson/worktree.rb', line 564

def self.branch_unpushed_issue( branch:, worktree_path:, runtime: )
	return nil unless branch

	remote = runtime.config.git_remote
	remote_ref = "#{remote}/#{branch}"
	ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: worktree_path )
	if !ahead_status.success?
		# Remote ref does not exist. Only block if the branch has unique commits vs main.
		unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{runtime.config.main_branch}..#{branch}", chdir: worktree_path )
		if unique_status.success? && unique.strip.to_i > 0
			# Content-aware check: after squash/rebase merge, commit SHAs differ
			# but the tree content may be identical to main. Compare content,
			# not SHAs — if the diff is empty, the work is already on main.
			_, _, diff_ok, = Open3.capture3( "git", "diff", "--quiet", runtime.config.main_branch, branch, chdir: worktree_path )
			unless diff_ok.success?
				return { error: "branch has not been pushed to #{remote}",
					recovery: "git -C #{worktree_path} push -u #{remote} #{branch}, or use --force to override" }
			end
			# Diff is empty — content is on main (squash/rebase merged). Safe.
			runtime.puts_verbose "branch #{branch} content matches main — squash/rebase merged, safe to remove"
		end
	elsif ahead.strip.to_i > 0
		return { error: "worktree has unpushed commits",
			recovery: "git -C #{worktree_path} push #{remote} #{branch}, or use --force to override" }
	end

	nil
end

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



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

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

	# Fetch to update the remote tracking ref without mutating the main worktree.
	# Best-effort — if fetch fails (no remote, offline), branch from local main.
	main_root = runtime.main_worktree_root
	remote = runtime.config.git_remote
	_, _, fetch_ok, = Open3.capture3( "git", "-C", main_root, "fetch", remote, base )
	if fetch_ok.success?
		remote_ref = "#{remote}/#{base}"
		_, _, ref_ok, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", remote_ref )
		if ref_ok.success?
			base = remote_ref
			runtime.puts_verbose( "branching from #{remote_ref}" ) unless json_output
		else
			runtime.puts_verbose( "fetch succeeded but #{remote_ref} not found — branching from local #{runtime.config.main_branch}" ) unless json_output
		end
	else
		runtime.puts_verbose( "fetch skipped — branching from local #{runtime.config.main_branch}" ) unless json_output
	end

	# 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( File.dirname( worktree_path ) )
	worktree_stdout, 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

	unless creation_verified?( path: worktree_path, branch: name, runtime: runtime )
		diagnostics = gather_create_diagnostics(
			git_stdout: worktree_stdout, git_stderr: worktree_stderr,
			name: name, runtime: runtime
		)
		cleanup_partial_create!( path: worktree_path, branch: name, runtime: runtime )
		return finish(
			result: { command: "worktree create", status: "error", name: name, path: worktree_path, branch: name,
				error: "git reported success but Carson could not verify the worktree and branch",
				recovery: "git worktree list --porcelain && git branch --list '#{name}'",
				diagnostics: diagnostics },
			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.



70
71
72
73
# File 'lib/carson/worktree.rb', line 70

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.



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
61
62
63
64
65
66
# File 'lib/carson/worktree.rb', line 30

def self.list( runtime: )
	raw = runtime.git_capture!( "worktree", "list", "--porcelain" )
	entries = []
	current_path = nil
	current_branch = :unset
	current_prunable_reason = nil
	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,
				prunable_reason: current_prunable_reason
			) if current_path
			current_path = nil
			current_branch = :unset
			current_prunable_reason = nil
		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
		elsif line.start_with?( "prunable" )
			reason = line.sub( "prunable", "" ).strip
			current_prunable_reason = reason.empty? ? "prunable" : reason
		end
	end
	entries << new(
		path: current_path,
		branch: current_branch == :unset ? nil : current_branch,
		runtime: runtime,
		prunable_reason: current_prunable_reason
	) if current_path
	entries
end

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

Returns true if the path is a registered git worktree.

Returns:

  • (Boolean)


76
77
78
79
# File 'lib/carson/worktree.rb', line 76

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

.remove!(path:, runtime:, force: false, skip_unpushed: 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.



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
241
242
243
244
245
# File 'lib/carson/worktree.rb', line 158

def self.remove!( path:, runtime:, force: false, skip_unpushed: 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

	check = remove_check( path: path, runtime: runtime, force: force, skip_unpushed: skip_unpushed )
	unless check.fetch( :status ) == :ok
		return finish(
			result: { command: "worktree remove", status: check.fetch( :result_status ), name: File.basename( check.fetch( :resolved_path ) ),
				branch: check.fetch( :branch, nil ),
				error: check.fetch( :error ),
				recovery: check.fetch( :recovery, nil ) },
			exit_code: check.fetch( :exit_code ), runtime: runtime, json_output: json_output
		)
	end

	resolved_path = check.fetch( :resolved_path )
	branch = check.fetch( :branch )

	# Missing directory: worktree was destroyed externally (e.g. gh pr merge
	# --delete-branch). Clean up the stale git registration and delete the branch.
	if check.fetch( :missing )
		return remove_missing!( resolved_path: resolved_path, runtime: runtime, json_output: json_output )
	end

	runtime.puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"

	# 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

.remove_check(path:, runtime:, force: false, skip_unpushed: false) ⇒ Object

Preflight guard for worktree removal. Shared by ‘worktree remove` and other runtime flows that need to know whether cleanup is safe before mutating GitHub or branch state.



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
276
277
278
279
280
281
282
283
284
285
286
287
288
289
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
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/carson/worktree.rb', line 250

def self.remove_check( path:, runtime:, force: false, skip_unpushed: false )
	resolved_path = resolve_path( path: path, runtime: runtime )

	if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
		entry = find( path: resolved_path, runtime: runtime )
		return { status: :ok, resolved_path: resolved_path, branch: entry&.branch, missing: true }
	end

	unless registered?( path: resolved_path, runtime: runtime )
		return {
			status: :error,
			result_status: "error",
			exit_code: Runtime::EXIT_ERROR,
			resolved_path: resolved_path,
			branch: nil,
			error: "#{resolved_path} is not a registered worktree",
			recovery: "git worktree list"
		}
	end

	entry = find( path: resolved_path, runtime: runtime )
	branch = entry&.branch

	if entry&.holds_cwd?
		safe_root = runtime.main_worktree_root
		return {
			status: :block,
			result_status: "block",
			exit_code: Runtime::EXIT_BLOCK,
			resolved_path: resolved_path,
			branch: branch,
			error: "current working directory is inside this worktree",
			recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}"
		}
	end

	if entry&.held_by_other_process?
		return {
			status: :block,
			result_status: "block",
			exit_code: Runtime::EXIT_BLOCK,
			resolved_path: resolved_path,
			branch: branch,
			error: "another process has its working directory inside this worktree",
			recovery: "wait for the other session to finish, then retry"
		}
	end

	if !force && entry&.dirty?
		return {
			status: :error,
			result_status: "error",
			exit_code: Runtime::EXIT_ERROR,
			resolved_path: resolved_path,
			branch: branch,
			error: "worktree has uncommitted changes",
			recovery: "commit or discard changes first, or use --force to override"
		}
	end

	unless force || skip_unpushed
		unpushed = branch_unpushed_issue( branch: branch, worktree_path: resolved_path, runtime: runtime )
		if unpushed
			return {
				status: :block,
				result_status: "block",
				exit_code: Runtime::EXIT_BLOCK,
				resolved_path: resolved_path,
				branch: branch,
				error: unpushed.fetch( :error ),
				recovery: unpushed.fetch( :recovery )
			}
		end
	end

	{ status: :ok, resolved_path: resolved_path, branch: branch, missing: false }
end

.sweep_stale!(runtime:) ⇒ Object

Removes agent-owned worktrees that the shared cleanup classifier judges safe to reap. Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/) under the main repo root.



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/carson/worktree.rb', line 331

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 ) }

		classification = runtime.send( :classify_worktree_cleanup, worktree: worktree )
		next unless classification.fetch( :action ) == :reap

		unless worktree.exists?
			remove_missing!( resolved_path: worktree.path, runtime: runtime, json_output: false )
			next
		end

		# Remove the worktree (no --force: automatic sweep never force-removes dirty worktrees).
		_, _, 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

#dirty?Boolean

Returns:

  • (Boolean)


414
415
416
417
418
419
420
421
# File 'lib/carson/worktree.rb', line 414

def dirty?
	return false unless exists?

	stdout, = Open3.capture3( "git", "status", "--porcelain", chdir: path )
	!stdout.to_s.strip.empty?
rescue StandardError
	false
end

#exists?Boolean

Returns:

  • (Boolean)


406
407
408
# File 'lib/carson/worktree.rb', line 406

def exists?
	Dir.exist?( path )
end

#held_by_other_process?Boolean

Does another process have its CWD inside this worktree?

Returns:

  • (Boolean)


380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/carson/worktree.rb', line 380

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)


370
371
372
373
374
375
376
377
# File 'lib/carson/worktree.rb', line 370

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

#prunable?Boolean

Returns:

  • (Boolean)


410
411
412
# File 'lib/carson/worktree.rb', line 410

def prunable?
	!prunable_reason.to_s.strip.empty?
end