carson 3.22.0 → 3.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/API.md +4 -9
- data/MANUAL.md +45 -28
- data/README.md +45 -50
- data/RELEASE.md +9 -1
- data/SKILL.md +1 -1
- data/VERSION +1 -1
- data/carson.gemspec +2 -4
- data/hooks/command-guard +1 -1
- data/hooks/pre-push +17 -20
- data/lib/carson/cli.rb +32 -16
- data/lib/carson/config.rb +46 -11
- data/lib/carson/runtime/audit.rb +37 -11
- data/lib/carson/runtime/deliver.rb +113 -42
- data/lib/carson/runtime/govern.rb +29 -17
- data/lib/carson/runtime/housekeep.rb +231 -25
- data/lib/carson/runtime/local/onboard.rb +24 -24
- data/lib/carson/runtime/local/prune.rb +116 -31
- data/lib/carson/runtime/local/sync.rb +29 -7
- data/lib/carson/runtime/local/template.rb +26 -8
- data/lib/carson/runtime/local/worktree.rb +37 -442
- data/lib/carson/runtime/review/gate_support.rb +131 -1
- data/lib/carson/runtime/review.rb +21 -77
- data/lib/carson/runtime/setup.rb +15 -6
- data/lib/carson/runtime/status.rb +35 -12
- data/lib/carson/runtime.rb +15 -3
- data/lib/carson/worktree.rb +497 -0
- data/lib/carson.rb +1 -0
- metadata +11 -16
- data/.github/copilot-instructions.md +0 -1
- data/.github/pull_request_template.md +0 -12
- data/templates/.github/AGENTS.md +0 -1
- data/templates/.github/CLAUDE.md +0 -1
- data/templates/.github/carson.md +0 -47
- data/templates/.github/copilot-instructions.md +0 -1
- data/templates/.github/pull_request_template.md +0 -12
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# Domain object representing a single git worktree entry.
|
|
2
|
+
# Owns its path, branch, and operating context (runtime).
|
|
3
|
+
# Answers state queries (CWD containment, process holds) and
|
|
4
|
+
# owns the full lifecycle: create, remove, list, sweep.
|
|
5
|
+
# Runtime provides infrastructure — git, config, output —
|
|
6
|
+
# the way ActiveRecord models hold a database connection.
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
|
|
11
|
+
module Carson
|
|
12
|
+
class Worktree
|
|
13
|
+
# Agent directory names whose worktrees Carson may sweep.
|
|
14
|
+
AGENT_DIRS = %w[ .claude .codex ].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :path, :branch
|
|
17
|
+
|
|
18
|
+
def initialize( path:, branch:, runtime: nil )
|
|
19
|
+
@path = path
|
|
20
|
+
@branch = branch
|
|
21
|
+
@runtime = runtime
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# --- Class lifecycle methods ---
|
|
25
|
+
|
|
26
|
+
# Parses `git worktree list --porcelain` into Worktree instances.
|
|
27
|
+
# Normalises paths with realpath so comparisons work across symlink differences.
|
|
28
|
+
def self.list( runtime: )
|
|
29
|
+
raw = runtime.git_capture!( "worktree", "list", "--porcelain" )
|
|
30
|
+
entries = []
|
|
31
|
+
current_path = nil
|
|
32
|
+
current_branch = :unset
|
|
33
|
+
raw.lines.each do |line|
|
|
34
|
+
line = line.strip
|
|
35
|
+
if line.empty?
|
|
36
|
+
entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
|
|
37
|
+
current_path = nil
|
|
38
|
+
current_branch = :unset
|
|
39
|
+
elsif line.start_with?( "worktree " )
|
|
40
|
+
current_path = runtime.realpath_safe( line.sub( "worktree ", "" ) )
|
|
41
|
+
elsif line.start_with?( "branch " )
|
|
42
|
+
current_branch = line.sub( "branch refs/heads/", "" )
|
|
43
|
+
elsif line == "detached"
|
|
44
|
+
current_branch = nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
|
|
48
|
+
entries
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Finds the Worktree entry for a given path, or nil.
|
|
52
|
+
# Compares using realpath to handle symlink differences.
|
|
53
|
+
def self.find( path:, runtime: )
|
|
54
|
+
canonical = runtime.realpath_safe( path )
|
|
55
|
+
list( runtime: runtime ).find { it.path == canonical }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns true if the path is a registered git worktree.
|
|
59
|
+
def self.registered?( path:, runtime: )
|
|
60
|
+
canonical = runtime.realpath_safe( path )
|
|
61
|
+
list( runtime: runtime ).any? { it.path == canonical }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
|
|
65
|
+
# Uses main_worktree_root so this works even when called from inside a worktree.
|
|
66
|
+
def self.create!( name:, runtime:, json_output: false )
|
|
67
|
+
worktrees_dir = File.join( runtime.main_worktree_root, ".claude", "worktrees" )
|
|
68
|
+
worktree_path = File.join( worktrees_dir, name )
|
|
69
|
+
|
|
70
|
+
if Dir.exist?( worktree_path )
|
|
71
|
+
return finish(
|
|
72
|
+
result: { command: "worktree create", status: "error", name: name, path: worktree_path,
|
|
73
|
+
error: "worktree already exists: #{name}",
|
|
74
|
+
recovery: "carson worktree remove #{name}, then retry" },
|
|
75
|
+
exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Determine the base branch (main branch from config).
|
|
80
|
+
base = runtime.config.main_branch
|
|
81
|
+
|
|
82
|
+
# Sync main from remote before branching so the worktree starts
|
|
83
|
+
# from the latest code. Prevents stale-base merge conflicts later.
|
|
84
|
+
# Best-effort — if pull fails (non-ff, offline), continue anyway.
|
|
85
|
+
main_root = runtime.main_worktree_root
|
|
86
|
+
_, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", runtime.config.git_remote, base )
|
|
87
|
+
runtime.puts_verbose pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}"
|
|
88
|
+
|
|
89
|
+
# Ensure .claude/ is excluded from git status in the host repository.
|
|
90
|
+
# Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
|
|
91
|
+
ensure_claude_dir_excluded!( runtime: runtime )
|
|
92
|
+
|
|
93
|
+
# Create the worktree with a new branch based on the main branch.
|
|
94
|
+
FileUtils.mkdir_p( worktrees_dir )
|
|
95
|
+
_, worktree_stderr, worktree_success, = runtime.git_run( "worktree", "add", worktree_path, "-b", name, base )
|
|
96
|
+
unless worktree_success
|
|
97
|
+
error_text = worktree_stderr.to_s.strip
|
|
98
|
+
error_text = "unable to create worktree" if error_text.empty?
|
|
99
|
+
return finish(
|
|
100
|
+
result: { command: "worktree create", status: "error", name: name,
|
|
101
|
+
error: error_text },
|
|
102
|
+
exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
finish(
|
|
107
|
+
result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
|
|
108
|
+
exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Removes a worktree: directory, git registration, and branch.
|
|
113
|
+
# Never forces removal — if the worktree has uncommitted changes, refuses unless
|
|
114
|
+
# the caller explicitly passes force: true via CLI --force flag.
|
|
115
|
+
def self.remove!( path:, runtime:, force: false, json_output: false )
|
|
116
|
+
fingerprint_status = runtime.block_if_outsider_fingerprints!
|
|
117
|
+
unless fingerprint_status.nil?
|
|
118
|
+
if json_output
|
|
119
|
+
runtime.output.puts JSON.pretty_generate( {
|
|
120
|
+
command: "worktree remove", status: "block",
|
|
121
|
+
error: "Carson-owned artefacts detected in host repository",
|
|
122
|
+
recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
|
|
123
|
+
exit_code: Runtime::EXIT_BLOCK
|
|
124
|
+
} )
|
|
125
|
+
end
|
|
126
|
+
return fingerprint_status
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
resolved_path = resolve_path( path: path, runtime: runtime )
|
|
130
|
+
|
|
131
|
+
# Missing directory: worktree was destroyed externally (e.g. gh pr merge
|
|
132
|
+
# --delete-branch). Clean up the stale git registration and delete the branch.
|
|
133
|
+
if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
|
|
134
|
+
return remove_missing!( resolved_path: resolved_path, runtime: runtime, json_output: json_output )
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless registered?( path: resolved_path, runtime: runtime )
|
|
138
|
+
return finish(
|
|
139
|
+
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
140
|
+
error: "#{resolved_path} is not a registered worktree",
|
|
141
|
+
recovery: "git worktree list" },
|
|
142
|
+
exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Safety: refuse if the caller's shell CWD is inside the worktree.
|
|
147
|
+
# Removing a directory while a shell is inside it kills the shell permanently.
|
|
148
|
+
entry = find( path: resolved_path, runtime: runtime )
|
|
149
|
+
if entry&.holds_cwd?
|
|
150
|
+
safe_root = runtime.main_worktree_root
|
|
151
|
+
return finish(
|
|
152
|
+
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
153
|
+
error: "current working directory is inside this worktree",
|
|
154
|
+
recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}" },
|
|
155
|
+
exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Safety: refuse if another process has its CWD inside the worktree.
|
|
160
|
+
# Protects against cross-process CWD crashes (e.g. an agent session
|
|
161
|
+
# removed by a separate cleanup process while the agent's shell is inside).
|
|
162
|
+
if entry&.held_by_other_process?
|
|
163
|
+
return finish(
|
|
164
|
+
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
165
|
+
error: "another process has its working directory inside this worktree",
|
|
166
|
+
recovery: "wait for the other session to finish, then retry" },
|
|
167
|
+
exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
branch = entry&.branch
|
|
172
|
+
runtime.puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
|
|
173
|
+
|
|
174
|
+
# Safety: refuse if the branch has unpushed commits (unless --force).
|
|
175
|
+
# Prevents accidental destruction of work that exists only locally.
|
|
176
|
+
unless force
|
|
177
|
+
unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path, runtime: runtime )
|
|
178
|
+
if unpushed
|
|
179
|
+
return finish(
|
|
180
|
+
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
181
|
+
branch: branch,
|
|
182
|
+
error: unpushed[ :error ],
|
|
183
|
+
recovery: unpushed[ :recovery ] },
|
|
184
|
+
exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Step 1: remove the worktree (directory + git registration).
|
|
190
|
+
rm_args = [ "worktree", "remove" ]
|
|
191
|
+
rm_args << "--force" if force
|
|
192
|
+
rm_args << resolved_path
|
|
193
|
+
_, rm_stderr, rm_success, = runtime.git_run( *rm_args )
|
|
194
|
+
unless rm_success
|
|
195
|
+
error_text = rm_stderr.to_s.strip
|
|
196
|
+
error_text = "unable to remove worktree" if error_text.empty?
|
|
197
|
+
if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
|
|
198
|
+
return finish(
|
|
199
|
+
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
200
|
+
error: "worktree has uncommitted changes",
|
|
201
|
+
recovery: "commit or discard changes first, or use --force to override" },
|
|
202
|
+
exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
return finish(
|
|
206
|
+
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
207
|
+
error: error_text },
|
|
208
|
+
exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
runtime.puts_verbose "worktree_removed: #{resolved_path}"
|
|
212
|
+
|
|
213
|
+
# Step 2: delete the local branch.
|
|
214
|
+
branch_deleted = false
|
|
215
|
+
if branch && !runtime.config.protected_branches.include?( branch )
|
|
216
|
+
_, del_stderr, del_success, = runtime.git_run( "branch", "-D", branch )
|
|
217
|
+
if del_success
|
|
218
|
+
runtime.puts_verbose "branch_deleted: #{branch}"
|
|
219
|
+
branch_deleted = true
|
|
220
|
+
else
|
|
221
|
+
runtime.puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Step 3: delete the remote branch (best-effort).
|
|
226
|
+
remote_deleted = false
|
|
227
|
+
if branch && !runtime.config.protected_branches.include?( branch )
|
|
228
|
+
remote_branch = branch
|
|
229
|
+
_, _, rd_success, = runtime.git_run( "push", runtime.config.git_remote, "--delete", remote_branch )
|
|
230
|
+
if rd_success
|
|
231
|
+
runtime.puts_verbose "remote_branch_deleted: #{runtime.config.git_remote}/#{remote_branch}"
|
|
232
|
+
remote_deleted = true
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
finish(
|
|
236
|
+
result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
|
|
237
|
+
branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
|
|
238
|
+
exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Removes agent-owned worktrees whose branch content is already on main.
|
|
243
|
+
# Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
|
|
244
|
+
# under the main repo root. Safe: skips detached HEADs, the caller's CWD,
|
|
245
|
+
# and dirty working trees (git worktree remove refuses without --force).
|
|
246
|
+
def self.sweep_stale!( runtime: )
|
|
247
|
+
main_root = runtime.main_worktree_root
|
|
248
|
+
worktrees = list( runtime: runtime )
|
|
249
|
+
|
|
250
|
+
agent_prefixes = AGENT_DIRS.filter_map do |dir|
|
|
251
|
+
full = File.join( main_root, dir, "worktrees" )
|
|
252
|
+
File.join( runtime.realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
253
|
+
end
|
|
254
|
+
return if agent_prefixes.empty?
|
|
255
|
+
|
|
256
|
+
worktrees.each do |worktree|
|
|
257
|
+
next unless worktree.branch
|
|
258
|
+
next unless agent_prefixes.any? { |prefix| worktree.path.start_with?( prefix ) }
|
|
259
|
+
next if worktree.holds_cwd?
|
|
260
|
+
next if worktree.held_by_other_process?
|
|
261
|
+
next unless runtime.branch_absorbed_into_main?( branch: worktree.branch )
|
|
262
|
+
|
|
263
|
+
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
264
|
+
_, _, rm_success, = runtime.git_run( "worktree", "remove", worktree.path )
|
|
265
|
+
next unless rm_success
|
|
266
|
+
|
|
267
|
+
runtime.puts_verbose "swept stale worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch})"
|
|
268
|
+
|
|
269
|
+
# Delete the local branch now that no worktree holds it.
|
|
270
|
+
if !runtime.config.protected_branches.include?( worktree.branch )
|
|
271
|
+
runtime.git_run( "branch", "-D", worktree.branch )
|
|
272
|
+
runtime.puts_verbose "deleted branch: #{worktree.branch}"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# --- Instance query methods ---
|
|
278
|
+
|
|
279
|
+
# Is the current process CWD inside this worktree?
|
|
280
|
+
def holds_cwd?
|
|
281
|
+
cwd = realpath_safe( Dir.pwd )
|
|
282
|
+
worktree = realpath_safe( path )
|
|
283
|
+
normalised = File.join( worktree, "" )
|
|
284
|
+
cwd == worktree || cwd.start_with?( normalised )
|
|
285
|
+
rescue StandardError
|
|
286
|
+
false
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Does another process have its CWD inside this worktree?
|
|
290
|
+
def held_by_other_process?
|
|
291
|
+
canonical = realpath_safe( path )
|
|
292
|
+
return false if canonical.nil? || canonical.empty?
|
|
293
|
+
return false unless Dir.exist?( canonical )
|
|
294
|
+
|
|
295
|
+
stdout, = Open3.capture3( "lsof", "-d", "cwd" )
|
|
296
|
+
# Do NOT gate on exit status — lsof exits non-zero on macOS when SIP blocks
|
|
297
|
+
# access to some system processes, even though user-process output is valid.
|
|
298
|
+
return false if stdout.nil? || stdout.empty?
|
|
299
|
+
|
|
300
|
+
normalised = File.join( canonical, "" )
|
|
301
|
+
my_pid = Process.pid
|
|
302
|
+
stdout.lines.drop( 1 ).any? do |line|
|
|
303
|
+
fields = line.strip.split( /\s+/ )
|
|
304
|
+
next false unless fields.length >= 9
|
|
305
|
+
next false if fields[ 1 ].to_i == my_pid
|
|
306
|
+
name = fields[ 8.. ].join( " " )
|
|
307
|
+
name == canonical || name.start_with?( normalised )
|
|
308
|
+
end
|
|
309
|
+
rescue Errno::ENOENT
|
|
310
|
+
# lsof not installed.
|
|
311
|
+
false
|
|
312
|
+
rescue StandardError
|
|
313
|
+
false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
|
|
317
|
+
private
|
|
318
|
+
# rubocop:enable Layout/AccessModifierIndentation
|
|
319
|
+
|
|
320
|
+
attr_reader :runtime
|
|
321
|
+
|
|
322
|
+
# --- Private class methods ---
|
|
323
|
+
|
|
324
|
+
# Handles removal when the worktree directory is already gone (destroyed
|
|
325
|
+
# externally by gh pr merge --delete-branch or manual deletion).
|
|
326
|
+
# Prunes the stale git worktree entry and cleans up the branch.
|
|
327
|
+
def self.remove_missing!( resolved_path:, runtime:, json_output: )
|
|
328
|
+
branch = find( path: resolved_path, runtime: runtime )&.branch
|
|
329
|
+
runtime.puts_verbose "worktree_remove_missing: path=#{resolved_path} branch=#{branch}"
|
|
330
|
+
|
|
331
|
+
# Prune the stale worktree entry from git's registry.
|
|
332
|
+
runtime.git_run( "worktree", "prune" )
|
|
333
|
+
runtime.puts_verbose "pruned stale worktree entry: #{resolved_path}"
|
|
334
|
+
|
|
335
|
+
# Delete the local branch.
|
|
336
|
+
branch_deleted = false
|
|
337
|
+
if branch && !runtime.config.protected_branches.include?( branch )
|
|
338
|
+
_, _, del_success, = runtime.git_run( "branch", "-D", branch )
|
|
339
|
+
if del_success
|
|
340
|
+
runtime.puts_verbose "branch_deleted: #{branch}"
|
|
341
|
+
branch_deleted = true
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Delete the remote branch (best-effort).
|
|
346
|
+
remote_deleted = false
|
|
347
|
+
if branch && !runtime.config.protected_branches.include?( branch )
|
|
348
|
+
_, _, rd_success, = runtime.git_run( "push", runtime.config.git_remote, "--delete", branch )
|
|
349
|
+
if rd_success
|
|
350
|
+
runtime.puts_verbose "remote_branch_deleted: #{runtime.config.git_remote}/#{branch}"
|
|
351
|
+
remote_deleted = true
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
finish(
|
|
356
|
+
result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
|
|
357
|
+
branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
|
|
358
|
+
exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
|
|
359
|
+
)
|
|
360
|
+
end
|
|
361
|
+
private_class_method :remove_missing!
|
|
362
|
+
|
|
363
|
+
# Unified output for worktree results — JSON or human-readable.
|
|
364
|
+
def self.finish( result:, exit_code:, runtime:, json_output: )
|
|
365
|
+
result[ :exit_code ] = exit_code
|
|
366
|
+
|
|
367
|
+
if json_output
|
|
368
|
+
runtime.output.puts JSON.pretty_generate( result )
|
|
369
|
+
else
|
|
370
|
+
print_human( result: result, runtime: runtime )
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
exit_code
|
|
374
|
+
end
|
|
375
|
+
private_class_method :finish
|
|
376
|
+
|
|
377
|
+
# Human-readable output for worktree results.
|
|
378
|
+
def self.print_human( result:, runtime: )
|
|
379
|
+
command = result[ :command ]
|
|
380
|
+
status = result[ :status ]
|
|
381
|
+
|
|
382
|
+
case status
|
|
383
|
+
when "ok"
|
|
384
|
+
case command
|
|
385
|
+
when "worktree create"
|
|
386
|
+
runtime.puts_line "Worktree created: #{result[ :name ]}"
|
|
387
|
+
runtime.puts_line " Path: #{result[ :path ]}"
|
|
388
|
+
runtime.puts_line " Branch: #{result[ :branch ]}"
|
|
389
|
+
when "worktree remove"
|
|
390
|
+
unless runtime.verbose?
|
|
391
|
+
runtime.puts_line "Worktree removed: #{result[ :name ]}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
when "error"
|
|
395
|
+
runtime.puts_line result[ :error ]
|
|
396
|
+
runtime.puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
397
|
+
when "block"
|
|
398
|
+
runtime.puts_line "#{result[ :error ]&.capitalize || 'Held'}: #{result[ :name ]}"
|
|
399
|
+
runtime.puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
private_class_method :print_human
|
|
403
|
+
|
|
404
|
+
# Checks whether a branch has unpushed commits that would be lost on removal.
|
|
405
|
+
# Content-aware: after squash/rebase merge, SHAs differ but tree content may match main.
|
|
406
|
+
# Compares content, not SHAs.
|
|
407
|
+
# Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
|
|
408
|
+
def self.check_unpushed_commits( branch:, worktree_path:, runtime: )
|
|
409
|
+
return nil unless branch
|
|
410
|
+
|
|
411
|
+
remote = runtime.config.git_remote
|
|
412
|
+
remote_ref = "#{remote}/#{branch}"
|
|
413
|
+
ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: worktree_path )
|
|
414
|
+
if !ahead_status.success?
|
|
415
|
+
# Remote ref does not exist. Only block if the branch has unique commits vs main.
|
|
416
|
+
unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{runtime.config.main_branch}..#{branch}", chdir: worktree_path )
|
|
417
|
+
if unique_status.success? && unique.strip.to_i > 0
|
|
418
|
+
# Content-aware check: after squash/rebase merge, commit SHAs differ
|
|
419
|
+
# but the tree content may be identical to main. Compare content,
|
|
420
|
+
# not SHAs — if the diff is empty, the work is already on main.
|
|
421
|
+
_, _, diff_ok, = Open3.capture3( "git", "diff", "--quiet", runtime.config.main_branch, branch, chdir: worktree_path )
|
|
422
|
+
unless diff_ok.success?
|
|
423
|
+
return { error: "branch has not been pushed to #{remote}",
|
|
424
|
+
recovery: "git -C #{worktree_path} push -u #{remote} #{branch}, or use --force to override" }
|
|
425
|
+
end
|
|
426
|
+
# Diff is empty — content is on main (squash/rebase merged). Safe.
|
|
427
|
+
runtime.puts_verbose "branch #{branch} content matches main — squash/rebase merged, safe to remove"
|
|
428
|
+
end
|
|
429
|
+
elsif ahead.strip.to_i > 0
|
|
430
|
+
return { error: "worktree has unpushed commits",
|
|
431
|
+
recovery: "git -C #{worktree_path} push #{remote} #{branch}, or use --force to override" }
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
nil
|
|
435
|
+
end
|
|
436
|
+
private_class_method :check_unpushed_commits
|
|
437
|
+
|
|
438
|
+
# Resolves a worktree path: if it's a bare name, first tries the flat
|
|
439
|
+
# .claude/worktrees/<name> convention; if that isn't registered, searches
|
|
440
|
+
# all registered worktrees for one whose directory name matches.
|
|
441
|
+
# This handles worktrees created by external tools (e.g. Claude Code) that
|
|
442
|
+
# nest under a subdirectory like .claude/worktrees/claude/<name>.
|
|
443
|
+
# Returns the canonical (realpath) form so comparisons against git worktree list
|
|
444
|
+
# succeed, even when the OS resolves symlinks differently.
|
|
445
|
+
# Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
|
|
446
|
+
def self.resolve_path( path:, runtime: )
|
|
447
|
+
if path.include?( "/" )
|
|
448
|
+
return runtime.realpath_safe( path )
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
root = runtime.main_worktree_root
|
|
452
|
+
candidate = File.join( root, ".claude", "worktrees", path )
|
|
453
|
+
canonical = runtime.realpath_safe( candidate )
|
|
454
|
+
return canonical if registered?( path: canonical, runtime: runtime )
|
|
455
|
+
|
|
456
|
+
# Bare name didn't match flat layout — search registered worktrees by dirname.
|
|
457
|
+
matches = list( runtime: runtime ).select { File.basename( it.path ) == path }
|
|
458
|
+
return matches.first.path if matches.size == 1
|
|
459
|
+
|
|
460
|
+
# No match or ambiguous — return the flat candidate and let the caller
|
|
461
|
+
# produce the appropriate error message.
|
|
462
|
+
canonical
|
|
463
|
+
end
|
|
464
|
+
private_class_method :resolve_path
|
|
465
|
+
|
|
466
|
+
# Adds .claude/ to .git/info/exclude if not already present.
|
|
467
|
+
# This prevents worktree directories from appearing as untracked files
|
|
468
|
+
# in the host repository. Uses the local exclude file (never committed)
|
|
469
|
+
# so the host repo's .gitignore is never touched.
|
|
470
|
+
# Uses main_worktree_root — worktrees have .git as a file, not a directory.
|
|
471
|
+
def self.ensure_claude_dir_excluded!( runtime: )
|
|
472
|
+
git_dir = File.join( runtime.main_worktree_root, ".git" )
|
|
473
|
+
return unless File.directory?( git_dir )
|
|
474
|
+
|
|
475
|
+
info_dir = File.join( git_dir, "info" )
|
|
476
|
+
exclude_path = File.join( info_dir, "exclude" )
|
|
477
|
+
|
|
478
|
+
FileUtils.mkdir_p( info_dir )
|
|
479
|
+
existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
|
|
480
|
+
return if existing.lines.any? { |line| line.strip == ".claude/" }
|
|
481
|
+
|
|
482
|
+
File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
|
|
483
|
+
rescue StandardError
|
|
484
|
+
# Best-effort — do not block worktree creation if exclude fails.
|
|
485
|
+
end
|
|
486
|
+
private_class_method :ensure_claude_dir_excluded!
|
|
487
|
+
|
|
488
|
+
# Instance-level realpath helper for query methods.
|
|
489
|
+
def realpath_safe( a_path )
|
|
490
|
+
return runtime.realpath_safe( a_path ) if runtime
|
|
491
|
+
|
|
492
|
+
File.realpath( a_path )
|
|
493
|
+
rescue Errno::ENOENT
|
|
494
|
+
File.expand_path( a_path )
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
data/lib/carson.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: carson
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.22.
|
|
4
|
+
version: 3.22.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -11,12 +11,13 @@ bindir: exe
|
|
|
11
11
|
cert_chain: []
|
|
12
12
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
13
13
|
dependencies: []
|
|
14
|
-
description: 'Carson is
|
|
15
|
-
governs — no Carson-owned artefacts in your repo.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
description: 'Carson is an autonomous git strategist and repositories governor that
|
|
15
|
+
lives outside the repositories it governs — no Carson-owned artefacts in your repo.
|
|
16
|
+
As strategist, Carson knows when to branch, how to isolate concurrent work, and
|
|
17
|
+
how to recover from failures. As governor, it enforces review gates, manages templates,
|
|
18
|
+
and triages every open PR across your portfolio: merge what''s ready, dispatch coding
|
|
19
|
+
agents to fix what''s failing, escalate what needs human judgement. One command,
|
|
20
|
+
all your projects, unmanned.'
|
|
20
21
|
email:
|
|
21
22
|
- wanghailei@users.noreply.github.com
|
|
22
23
|
executables:
|
|
@@ -24,8 +25,6 @@ executables:
|
|
|
24
25
|
extensions: []
|
|
25
26
|
extra_rdoc_files: []
|
|
26
27
|
files:
|
|
27
|
-
- ".github/copilot-instructions.md"
|
|
28
|
-
- ".github/pull_request_template.md"
|
|
29
28
|
- ".github/workflows/carson_policy.yml"
|
|
30
29
|
- API.md
|
|
31
30
|
- LICENSE
|
|
@@ -73,11 +72,7 @@ files:
|
|
|
73
72
|
- lib/carson/runtime/setup.rb
|
|
74
73
|
- lib/carson/runtime/status.rb
|
|
75
74
|
- lib/carson/version.rb
|
|
76
|
-
-
|
|
77
|
-
- templates/.github/CLAUDE.md
|
|
78
|
-
- templates/.github/carson.md
|
|
79
|
-
- templates/.github/copilot-instructions.md
|
|
80
|
-
- templates/.github/pull_request_template.md
|
|
75
|
+
- lib/carson/worktree.rb
|
|
81
76
|
homepage: https://github.com/wanghailei/carson
|
|
82
77
|
licenses:
|
|
83
78
|
- PolyForm-Shield-1.0.0
|
|
@@ -106,6 +101,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
106
101
|
requirements: []
|
|
107
102
|
rubygems_version: 4.0.3
|
|
108
103
|
specification_version: 4
|
|
109
|
-
summary: Autonomous
|
|
110
|
-
else.
|
|
104
|
+
summary: Autonomous git strategist and repositories governor — you write the code,
|
|
105
|
+
Carson manages everything else.
|
|
111
106
|
test_files: []
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Read `.github/AGENTS.md` for repository governance rules enforced by Carson.
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
## Shared Scope and Validation
|
|
2
|
-
|
|
3
|
-
- [ ] `single_business_intent`: this PR is one coherent domain or feature intent.
|
|
4
|
-
- [ ] `carson audit` before commit.
|
|
5
|
-
- [ ] `carson audit` before push.
|
|
6
|
-
- [ ] `gh pr list --state open --limit 50` checked at session start (capture competing active PRs).
|
|
7
|
-
- [ ] `gh pr list --state open --limit 50` re-checked immediately before merge decision.
|
|
8
|
-
- [ ] Required CI checks are passing.
|
|
9
|
-
- [ ] At least 60 seconds passed since the last push to allow AI reviewers to post.
|
|
10
|
-
- [ ] No unresolved required conversation threads at merge time.
|
|
11
|
-
- [ ] `carson review gate` passes with converged snapshots.
|
|
12
|
-
- [ ] Every actionable top-level review item has a `Disposition:` disposition (`accepted`, `rejected`, `deferred`) with the target review URL.
|
data/templates/.github/AGENTS.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Read `.github/carson.md` for repository governance rules enforced by Carson.
|
data/templates/.github/CLAUDE.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Read `.github/AGENTS.md` for repository governance rules enforced by Carson.
|
data/templates/.github/carson.md
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# Carson Governance
|
|
2
|
-
|
|
3
|
-
This repository is governed by [Carson](https://github.com/wanghailei/carson), an autonomous governance runtime. Carson lives on the maintainer's workstation, not inside this repository.
|
|
4
|
-
|
|
5
|
-
## Commands
|
|
6
|
-
|
|
7
|
-
**Delivery:**
|
|
8
|
-
```bash
|
|
9
|
-
carson deliver # push branch, create PR
|
|
10
|
-
carson deliver --merge # push, create PR, merge if CI green and review clear
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
**Before committing:**
|
|
14
|
-
```bash
|
|
15
|
-
carson audit # full governance check — run before every commit
|
|
16
|
-
carson template check # detect drift in .github/* managed files, including stale superseded files
|
|
17
|
-
carson template apply # fix drift and remove superseded files
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
**Before recommending merge:**
|
|
21
|
-
```bash
|
|
22
|
-
carson review gate # block until actionable review findings are resolved
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**Branch housekeeping:**
|
|
26
|
-
```bash
|
|
27
|
-
carson sync # fast-forward local main from remote
|
|
28
|
-
carson prune # remove stale branches (safer than git branch -d on squash repos)
|
|
29
|
-
carson housekeep # sync + prune + sweep stale worktrees
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Exit Codes
|
|
33
|
-
|
|
34
|
-
- `0` — success
|
|
35
|
-
- `1` — runtime or configuration error
|
|
36
|
-
- `2` — policy blocked (hard stop; treat as expected failure in CI)
|
|
37
|
-
|
|
38
|
-
## Governance Rules
|
|
39
|
-
|
|
40
|
-
- Before commit and before push, run `carson audit`.
|
|
41
|
-
- At session start and again immediately before merge recommendation, run `gh pr list --state open --limit 50` and re-confirm active PR priorities.
|
|
42
|
-
- Before merge recommendation, run `carson review gate`; it enforces warm-up wait, unresolved-thread convergence, and `Disposition:` acknowledgements for actionable top-level findings.
|
|
43
|
-
- Actionable findings are unresolved review threads, any non-author `CHANGES_REQUESTED` review, or non-author comments/reviews with risk keywords (`bug`, `security`, `incorrect`, `block`, `fail`, `regression`).
|
|
44
|
-
- `Disposition:` responses must include one token (`accepted`, `rejected`, `deferred`) and the target review URL.
|
|
45
|
-
- Scheduled governance runs `carson review sweep` every 8 hours to track late actionable review activity.
|
|
46
|
-
- Do not treat green checks or `mergeStateStatus: CLEAN` as sufficient if unresolved review threads remain.
|
|
47
|
-
- Never suggest destructive operations on protected refs (`main`/`master`, local or remote).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Read `.github/AGENTS.md` for repository governance rules enforced by Carson.
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
## Shared Scope and Validation
|
|
2
|
-
|
|
3
|
-
- [ ] `single_business_intent`: this PR is one coherent domain or feature intent.
|
|
4
|
-
- [ ] `carson audit` before commit.
|
|
5
|
-
- [ ] `carson audit` before push.
|
|
6
|
-
- [ ] `gh pr list --state open --limit 50` checked at session start (capture competing active PRs).
|
|
7
|
-
- [ ] `gh pr list --state open --limit 50` re-checked immediately before merge decision.
|
|
8
|
-
- [ ] Required CI checks are passing.
|
|
9
|
-
- [ ] At least 60 seconds passed since the last push to allow AI reviewers to post.
|
|
10
|
-
- [ ] No unresolved required conversation threads at merge time.
|
|
11
|
-
- [ ] `carson review gate` passes with converged snapshots.
|
|
12
|
-
- [ ] Every actionable top-level review item has a `Disposition:` disposition (`accepted`, `rejected`, `deferred`) with the target review URL.
|