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
|
@@ -1,384 +1,36 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
# CWD
|
|
5
|
-
# squash/rebase merge without --force.
|
|
6
|
-
# Supports --json for machine-readable structured output with recovery commands.
|
|
1
|
+
# Thin worktree delegate layer on Runtime.
|
|
2
|
+
# Lifecycle operations live on Carson::Worktree; this module delegates
|
|
3
|
+
# and keeps only methods that genuinely belong on Runtime (path resolution,
|
|
4
|
+
# CWD branch detection).
|
|
7
5
|
module Carson
|
|
8
6
|
class Runtime
|
|
9
7
|
module Local
|
|
10
8
|
|
|
11
|
-
#
|
|
12
|
-
AGENT_WORKTREE_DIRS = %w[ .claude .codex ].freeze
|
|
9
|
+
# --- Delegates to Carson::Worktree ---
|
|
13
10
|
|
|
14
|
-
# Creates a new worktree under .claude/worktrees/<name
|
|
15
|
-
# Uses main_worktree_root so this works even when called from inside a worktree.
|
|
11
|
+
# Creates a new worktree under .claude/worktrees/<name>.
|
|
16
12
|
def worktree_create!( name:, json_output: false )
|
|
17
|
-
|
|
18
|
-
worktree_path = File.join( worktrees_dir, name )
|
|
19
|
-
|
|
20
|
-
if Dir.exist?( worktree_path )
|
|
21
|
-
return worktree_finish(
|
|
22
|
-
result: { command: "worktree create", status: "error", name: name, path: worktree_path,
|
|
23
|
-
error: "worktree already exists: #{name}",
|
|
24
|
-
recovery: "carson worktree remove #{name}, then retry" },
|
|
25
|
-
exit_code: EXIT_ERROR, json_output: json_output
|
|
26
|
-
)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Determine the base branch (main branch from config).
|
|
30
|
-
base = config.main_branch
|
|
31
|
-
|
|
32
|
-
# Sync main from remote before branching so the worktree starts
|
|
33
|
-
# from the latest code. Prevents stale-base merge conflicts later.
|
|
34
|
-
# Best-effort — if pull fails (non-ff, offline), continue anyway.
|
|
35
|
-
main_root = main_worktree_root
|
|
36
|
-
_, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", config.git_remote, base )
|
|
37
|
-
puts_verbose pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}"
|
|
38
|
-
|
|
39
|
-
# Ensure .claude/ is excluded from git status in the host repository.
|
|
40
|
-
# Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
|
|
41
|
-
ensure_claude_dir_excluded!
|
|
42
|
-
|
|
43
|
-
# Create the worktree with a new branch based on the main branch.
|
|
44
|
-
FileUtils.mkdir_p( worktrees_dir )
|
|
45
|
-
_, worktree_stderr, worktree_success, = git_run( "worktree", "add", worktree_path, "-b", name, base )
|
|
46
|
-
unless worktree_success
|
|
47
|
-
error_text = worktree_stderr.to_s.strip
|
|
48
|
-
error_text = "unable to create worktree" if error_text.empty?
|
|
49
|
-
return worktree_finish(
|
|
50
|
-
result: { command: "worktree create", status: "error", name: name,
|
|
51
|
-
error: error_text },
|
|
52
|
-
exit_code: EXIT_ERROR, json_output: json_output
|
|
53
|
-
)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
worktree_finish(
|
|
57
|
-
result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
|
|
58
|
-
exit_code: EXIT_OK, json_output: json_output
|
|
59
|
-
)
|
|
13
|
+
Worktree.create!( name: name, runtime: self, json_output: json_output )
|
|
60
14
|
end
|
|
61
15
|
|
|
62
16
|
# Removes a worktree: directory, git registration, and branch.
|
|
63
|
-
# Never forces removal — if the worktree has uncommitted changes, refuses unless
|
|
64
|
-
# the user explicitly passes force: true via CLI --force flag.
|
|
65
17
|
def worktree_remove!( worktree_path:, force: false, json_output: false )
|
|
66
|
-
|
|
67
|
-
unless fingerprint_status.nil?
|
|
68
|
-
if json_output
|
|
69
|
-
output.puts JSON.pretty_generate( {
|
|
70
|
-
command: "worktree remove", status: "block",
|
|
71
|
-
error: "Carson-owned artefacts detected in host repository",
|
|
72
|
-
recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
|
|
73
|
-
exit_code: EXIT_BLOCK
|
|
74
|
-
} )
|
|
75
|
-
end
|
|
76
|
-
return fingerprint_status
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
resolved_path = resolve_worktree_path( worktree_path: worktree_path )
|
|
80
|
-
|
|
81
|
-
# Missing directory: worktree was destroyed externally (e.g. gh pr merge
|
|
82
|
-
# --delete-branch). Clean up the stale git registration and delete the branch.
|
|
83
|
-
if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
|
|
84
|
-
return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
unless worktree_registered?( path: resolved_path )
|
|
88
|
-
return worktree_finish(
|
|
89
|
-
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
90
|
-
error: "#{resolved_path} is not a registered worktree",
|
|
91
|
-
recovery: "git worktree list" },
|
|
92
|
-
exit_code: EXIT_ERROR, json_output: json_output
|
|
93
|
-
)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Safety: refuse if the caller's shell CWD is inside the worktree.
|
|
97
|
-
# Removing a directory while a shell is inside it kills the shell permanently.
|
|
98
|
-
if cwd_inside_worktree?( worktree_path: resolved_path )
|
|
99
|
-
safe_root = main_worktree_root
|
|
100
|
-
return worktree_finish(
|
|
101
|
-
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
102
|
-
error: "current working directory is inside this worktree",
|
|
103
|
-
recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}" },
|
|
104
|
-
exit_code: EXIT_BLOCK, json_output: json_output
|
|
105
|
-
)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Safety: refuse if another process has its CWD inside the worktree.
|
|
109
|
-
# Protects against cross-process CWD crashes (e.g. an agent session
|
|
110
|
-
# removed by a separate cleanup process while the agent's shell is inside).
|
|
111
|
-
if worktree_held_by_other_process?( worktree_path: resolved_path )
|
|
112
|
-
return worktree_finish(
|
|
113
|
-
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
114
|
-
error: "another process has its working directory inside this worktree",
|
|
115
|
-
recovery: "wait for the other session to finish, then retry" },
|
|
116
|
-
exit_code: EXIT_BLOCK, json_output: json_output
|
|
117
|
-
)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
branch = worktree_branch( path: resolved_path )
|
|
121
|
-
puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
|
|
122
|
-
|
|
123
|
-
# Safety: refuse if the branch has unpushed commits (unless --force).
|
|
124
|
-
# Prevents accidental destruction of work that exists only locally.
|
|
125
|
-
unless force
|
|
126
|
-
unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
|
|
127
|
-
if unpushed
|
|
128
|
-
return worktree_finish(
|
|
129
|
-
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
130
|
-
branch: branch,
|
|
131
|
-
error: unpushed[ :error ],
|
|
132
|
-
recovery: unpushed[ :recovery ] },
|
|
133
|
-
exit_code: EXIT_BLOCK, json_output: json_output
|
|
134
|
-
)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# Step 1: remove the worktree (directory + git registration).
|
|
139
|
-
rm_args = [ "worktree", "remove" ]
|
|
140
|
-
rm_args << "--force" if force
|
|
141
|
-
rm_args << resolved_path
|
|
142
|
-
rm_stdout, rm_stderr, rm_success, = git_run( *rm_args )
|
|
143
|
-
unless rm_success
|
|
144
|
-
error_text = rm_stderr.to_s.strip
|
|
145
|
-
error_text = "unable to remove worktree" if error_text.empty?
|
|
146
|
-
if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
|
|
147
|
-
return worktree_finish(
|
|
148
|
-
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
149
|
-
error: "worktree has uncommitted changes",
|
|
150
|
-
recovery: "commit or discard changes first, or use --force to override" },
|
|
151
|
-
exit_code: EXIT_ERROR, json_output: json_output
|
|
152
|
-
)
|
|
153
|
-
end
|
|
154
|
-
return worktree_finish(
|
|
155
|
-
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
156
|
-
error: error_text },
|
|
157
|
-
exit_code: EXIT_ERROR, json_output: json_output
|
|
158
|
-
)
|
|
159
|
-
end
|
|
160
|
-
puts_verbose "worktree_removed: #{resolved_path}"
|
|
161
|
-
|
|
162
|
-
# Step 2: delete the local branch.
|
|
163
|
-
branch_deleted = false
|
|
164
|
-
if branch && !config.protected_branches.include?( branch )
|
|
165
|
-
_, del_stderr, del_success, = git_run( "branch", "-D", branch )
|
|
166
|
-
if del_success
|
|
167
|
-
puts_verbose "branch_deleted: #{branch}"
|
|
168
|
-
branch_deleted = true
|
|
169
|
-
else
|
|
170
|
-
puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Step 3: delete the remote branch (best-effort).
|
|
175
|
-
remote_deleted = false
|
|
176
|
-
if branch && !config.protected_branches.include?( branch )
|
|
177
|
-
remote_branch = branch
|
|
178
|
-
_, _, rd_success, = git_run( "push", config.git_remote, "--delete", remote_branch )
|
|
179
|
-
if rd_success
|
|
180
|
-
puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
|
|
181
|
-
remote_deleted = true
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
worktree_finish(
|
|
185
|
-
result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
|
|
186
|
-
branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
|
|
187
|
-
exit_code: EXIT_OK, json_output: json_output
|
|
188
|
-
)
|
|
18
|
+
Worktree.remove!( path: worktree_path, runtime: self, force: force, json_output: json_output )
|
|
189
19
|
end
|
|
190
20
|
|
|
191
21
|
# Removes agent-owned worktrees whose branch content is already on main.
|
|
192
|
-
# Scans AGENT_WORKTREE_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
|
|
193
|
-
# under the main repo root. Safe: skips detached HEADs, the caller's CWD,
|
|
194
|
-
# and dirty working trees (git worktree remove refuses without --force).
|
|
195
22
|
def sweep_stale_worktrees!
|
|
196
|
-
|
|
197
|
-
worktrees = worktree_list
|
|
198
|
-
|
|
199
|
-
agent_prefixes = AGENT_WORKTREE_DIRS.filter_map do |dir|
|
|
200
|
-
full = File.join( main_root, dir, "worktrees" )
|
|
201
|
-
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
202
|
-
end
|
|
203
|
-
return if agent_prefixes.empty?
|
|
204
|
-
|
|
205
|
-
worktrees.each do |worktree|
|
|
206
|
-
path = worktree.fetch( :path )
|
|
207
|
-
branch = worktree.fetch( :branch, nil )
|
|
208
|
-
next unless branch
|
|
209
|
-
next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
|
|
210
|
-
next if cwd_inside_worktree?( worktree_path: path )
|
|
211
|
-
next if worktree_held_by_other_process?( worktree_path: path )
|
|
212
|
-
next unless branch_absorbed_into_main?( branch: branch )
|
|
213
|
-
|
|
214
|
-
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
215
|
-
_, _, rm_success, = git_run( "worktree", "remove", path )
|
|
216
|
-
next unless rm_success
|
|
217
|
-
|
|
218
|
-
puts_verbose "swept stale worktree: #{File.basename( path )} (branch: #{branch})"
|
|
219
|
-
|
|
220
|
-
# Delete the local branch now that no worktree holds it.
|
|
221
|
-
if !config.protected_branches.include?( branch )
|
|
222
|
-
git_run( "branch", "-D", branch )
|
|
223
|
-
puts_verbose "deleted branch: #{branch}"
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
private
|
|
229
|
-
|
|
230
|
-
# Handles removal when the worktree directory is already gone (destroyed
|
|
231
|
-
# externally by gh pr merge --delete-branch or manual deletion).
|
|
232
|
-
# Prunes the stale git worktree entry and cleans up the branch.
|
|
233
|
-
def worktree_remove_missing!( resolved_path:, json_output: )
|
|
234
|
-
branch = worktree_branch( path: resolved_path )
|
|
235
|
-
puts_verbose "worktree_remove_missing: path=#{resolved_path} branch=#{branch}"
|
|
236
|
-
|
|
237
|
-
# Prune the stale worktree entry from git's registry.
|
|
238
|
-
git_run( "worktree", "prune" )
|
|
239
|
-
puts_verbose "pruned stale worktree entry: #{resolved_path}"
|
|
240
|
-
|
|
241
|
-
# Delete the local branch.
|
|
242
|
-
branch_deleted = false
|
|
243
|
-
if branch && !config.protected_branches.include?( branch )
|
|
244
|
-
_, _, del_success, = git_run( "branch", "-D", branch )
|
|
245
|
-
if del_success
|
|
246
|
-
puts_verbose "branch_deleted: #{branch}"
|
|
247
|
-
branch_deleted = true
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
# Delete the remote branch (best-effort).
|
|
252
|
-
remote_deleted = false
|
|
253
|
-
if branch && !config.protected_branches.include?( branch )
|
|
254
|
-
_, _, rd_success, = git_run( "push", config.git_remote, "--delete", branch )
|
|
255
|
-
if rd_success
|
|
256
|
-
puts_verbose "remote_branch_deleted: #{config.git_remote}/#{branch}"
|
|
257
|
-
remote_deleted = true
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
worktree_finish(
|
|
262
|
-
result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
|
|
263
|
-
branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
|
|
264
|
-
exit_code: EXIT_OK, json_output: json_output
|
|
265
|
-
)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# Unified output for worktree results — JSON or human-readable.
|
|
269
|
-
def worktree_finish( result:, exit_code:, json_output: )
|
|
270
|
-
result[ :exit_code ] = exit_code
|
|
271
|
-
|
|
272
|
-
if json_output
|
|
273
|
-
output.puts JSON.pretty_generate( result )
|
|
274
|
-
else
|
|
275
|
-
print_worktree_human( result: result )
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
exit_code
|
|
23
|
+
Worktree.sweep_stale!( runtime: self )
|
|
279
24
|
end
|
|
280
25
|
|
|
281
|
-
#
|
|
282
|
-
def
|
|
283
|
-
|
|
284
|
-
status = result[ :status ]
|
|
285
|
-
|
|
286
|
-
case status
|
|
287
|
-
when "ok"
|
|
288
|
-
case command
|
|
289
|
-
when "worktree create"
|
|
290
|
-
puts_line "Worktree created: #{result[ :name ]}"
|
|
291
|
-
puts_line " Path: #{result[ :path ]}"
|
|
292
|
-
puts_line " Branch: #{result[ :branch ]}"
|
|
293
|
-
when "worktree remove"
|
|
294
|
-
unless verbose?
|
|
295
|
-
puts_line "Worktree removed: #{result[ :name ]}"
|
|
296
|
-
end
|
|
297
|
-
end
|
|
298
|
-
when "error"
|
|
299
|
-
puts_line "ERROR: #{result[ :error ]}"
|
|
300
|
-
puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
|
|
301
|
-
when "block"
|
|
302
|
-
puts_line "#{result[ :error ]&.capitalize || 'Blocked'}: #{result[ :name ]}"
|
|
303
|
-
puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# Returns true when the process CWD is inside the given worktree path.
|
|
308
|
-
# This detects the most common session-crash scenario: removing a worktree
|
|
309
|
-
# while the caller's shell is inside it.
|
|
310
|
-
# Uses realpath on both sides to handle symlink differences (e.g. /tmp vs /private/tmp).
|
|
311
|
-
def cwd_inside_worktree?( worktree_path: )
|
|
312
|
-
cwd = realpath_safe( Dir.pwd )
|
|
313
|
-
worktree = realpath_safe( worktree_path )
|
|
314
|
-
normalised_wt = File.join( worktree, "" )
|
|
315
|
-
cwd == worktree || cwd.start_with?( normalised_wt )
|
|
316
|
-
rescue StandardError
|
|
317
|
-
false
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
# Checks whether any other process has its working directory inside the worktree.
|
|
321
|
-
# Uses lsof to query CWD file descriptors system-wide, then matches against
|
|
322
|
-
# the worktree path. Catches the cross-process CWD crash scenario: a cleanup
|
|
323
|
-
# process removing a worktree while another session's shell is still inside it.
|
|
324
|
-
# Fails safe: returns false if lsof is unavailable or any error occurs.
|
|
325
|
-
def worktree_held_by_other_process?( worktree_path: )
|
|
326
|
-
canonical = realpath_safe( worktree_path )
|
|
327
|
-
return false if canonical.nil? || canonical.empty?
|
|
328
|
-
return false unless Dir.exist?( canonical )
|
|
329
|
-
|
|
330
|
-
stdout, _, status = Open3.capture3( "lsof", "-d", "cwd" )
|
|
331
|
-
return false unless status.success?
|
|
332
|
-
|
|
333
|
-
normalised = File.join( canonical, "" )
|
|
334
|
-
my_pid = Process.pid
|
|
335
|
-
stdout.lines.drop( 1 ).any? do |line|
|
|
336
|
-
fields = line.strip.split( /\s+/ )
|
|
337
|
-
next false unless fields.length >= 9
|
|
338
|
-
next false if fields[ 1 ].to_i == my_pid
|
|
339
|
-
name = fields[ 8.. ].join( " " )
|
|
340
|
-
name == canonical || name.start_with?( normalised )
|
|
341
|
-
end
|
|
342
|
-
rescue Errno::ENOENT
|
|
343
|
-
# lsof not installed.
|
|
344
|
-
false
|
|
345
|
-
rescue StandardError
|
|
346
|
-
false
|
|
26
|
+
# Returns all registered worktrees as Carson::Worktree instances.
|
|
27
|
+
def worktree_list
|
|
28
|
+
Worktree.list( runtime: self )
|
|
347
29
|
end
|
|
348
30
|
|
|
349
|
-
#
|
|
350
|
-
# Content-aware: after squash/rebase merge, SHAs differ but tree content may match main. Compares content, not SHAs.
|
|
351
|
-
# Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
|
|
352
|
-
def check_unpushed_commits( branch:, worktree_path: )
|
|
353
|
-
return nil unless branch
|
|
354
|
-
|
|
355
|
-
remote = config.git_remote
|
|
356
|
-
remote_ref = "#{remote}/#{branch}"
|
|
357
|
-
ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: worktree_path )
|
|
358
|
-
if !ahead_status.success?
|
|
359
|
-
# Remote ref does not exist. Only block if the branch has unique commits vs main.
|
|
360
|
-
unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{config.main_branch}..#{branch}", chdir: worktree_path )
|
|
361
|
-
if unique_status.success? && unique.strip.to_i > 0
|
|
362
|
-
# Content-aware check: after squash/rebase merge, commit SHAs differ
|
|
363
|
-
# but the tree content may be identical to main. Compare content,
|
|
364
|
-
# not SHAs — if the diff is empty, the work is already on main.
|
|
365
|
-
_, _, diff_ok, = Open3.capture3( "git", "diff", "--quiet", config.main_branch, branch, chdir: worktree_path )
|
|
366
|
-
unless diff_ok.success?
|
|
367
|
-
return { error: "branch has not been pushed to #{remote}",
|
|
368
|
-
recovery: "git -C #{worktree_path} push -u #{remote} #{branch}, or use --force to override" }
|
|
369
|
-
end
|
|
370
|
-
# Diff is empty — content is on main (squash/rebase merged). Safe.
|
|
371
|
-
puts_verbose "branch #{branch} content matches main — squash/rebase merged, safe to remove"
|
|
372
|
-
end
|
|
373
|
-
elsif ahead.strip.to_i > 0
|
|
374
|
-
return { error: "worktree has unpushed commits",
|
|
375
|
-
recovery: "git -C #{worktree_path} push #{remote} #{branch}, or use --force to override" }
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
nil
|
|
379
|
-
end
|
|
31
|
+
# --- Methods that stay on Runtime ---
|
|
380
32
|
|
|
381
|
-
# Returns the branch checked
|
|
33
|
+
# Returns the branch checked out in the worktree that contains the process CWD,
|
|
382
34
|
# or nil if CWD is not inside any worktree. Used by prune to proactively
|
|
383
35
|
# protect the CWD worktree's branch from deletion.
|
|
384
36
|
# Matches the longest (most specific) path because worktree directories
|
|
@@ -388,11 +40,10 @@ module Carson
|
|
|
388
40
|
best_branch = nil
|
|
389
41
|
best_length = -1
|
|
390
42
|
worktree_list.each do |worktree|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
best_length = worktree_path.length
|
|
43
|
+
normalised = File.join( worktree.path, "" )
|
|
44
|
+
if ( cwd == worktree.path || cwd.start_with?( normalised ) ) && worktree.path.length > best_length
|
|
45
|
+
best_branch = worktree.branch
|
|
46
|
+
best_length = worktree.path.length
|
|
396
47
|
end
|
|
397
48
|
end
|
|
398
49
|
best_branch
|
|
@@ -410,87 +61,31 @@ module Carson
|
|
|
410
61
|
repo_root
|
|
411
62
|
end
|
|
412
63
|
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
info_dir = File.join( git_dir, "info" )
|
|
423
|
-
exclude_path = File.join( info_dir, "exclude" )
|
|
424
|
-
|
|
425
|
-
FileUtils.mkdir_p( info_dir )
|
|
426
|
-
existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
|
|
427
|
-
return if existing.lines.any? { |line| line.strip == ".claude/" }
|
|
64
|
+
# Resolves a path to its canonical form, tolerating non-existent paths.
|
|
65
|
+
# Preserves canonical parents for missing paths so deleted worktrees still
|
|
66
|
+
# compare equal to git's recorded path (for example /tmp vs /private/tmp).
|
|
67
|
+
def realpath_safe( path )
|
|
68
|
+
File.realpath( path )
|
|
69
|
+
rescue Errno::ENOENT
|
|
70
|
+
expanded = File.expand_path( path )
|
|
71
|
+
missing_segments = []
|
|
72
|
+
candidate = expanded
|
|
428
73
|
|
|
429
|
-
File.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
end
|
|
74
|
+
until File.exist?( candidate ) || Dir.exist?( candidate )
|
|
75
|
+
parent = File.dirname( candidate )
|
|
76
|
+
break if parent == candidate
|
|
433
77
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
# gh pr merge --delete-branch deleted it externally).
|
|
437
|
-
# Returns the canonical (realpath) form so comparisons against git worktree list
|
|
438
|
-
# succeed, even when the OS resolves symlinks differently (e.g. /tmp → /private/tmp).
|
|
439
|
-
# Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
|
|
440
|
-
def resolve_worktree_path( worktree_path: )
|
|
441
|
-
if worktree_path.include?( "/" )
|
|
442
|
-
return realpath_safe( worktree_path )
|
|
78
|
+
missing_segments.unshift( File.basename( candidate ) )
|
|
79
|
+
candidate = parent
|
|
443
80
|
end
|
|
444
81
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
# Returns true if the path is a registered git worktree.
|
|
451
|
-
# Compares using realpath to handle symlink differences.
|
|
452
|
-
def worktree_registered?( path: )
|
|
453
|
-
canonical = realpath_safe( path )
|
|
454
|
-
worktree_list.any? { |worktree| worktree.fetch( :path ) == canonical }
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
# Returns the branch name checked output in a worktree, or nil.
|
|
458
|
-
# Compares using realpath to handle symlink differences.
|
|
459
|
-
def worktree_branch( path: )
|
|
460
|
-
canonical = realpath_safe( path )
|
|
461
|
-
entry = worktree_list.find { |worktree| worktree.fetch( :path ) == canonical }
|
|
462
|
-
entry&.fetch( :branch, nil )
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
# Parses `git worktree list --porcelain` into structured entries.
|
|
466
|
-
# Normalises paths with realpath so comparisons work across symlink differences.
|
|
467
|
-
def worktree_list
|
|
468
|
-
output = git_capture!( "worktree", "list", "--porcelain" )
|
|
469
|
-
entries = []
|
|
470
|
-
current = {}
|
|
471
|
-
output.lines.each do |line|
|
|
472
|
-
line = line.strip
|
|
473
|
-
if line.empty?
|
|
474
|
-
entries << current unless current.empty?
|
|
475
|
-
current = {}
|
|
476
|
-
elsif line.start_with?( "worktree " )
|
|
477
|
-
current[ :path ] = realpath_safe( line.sub( "worktree ", "" ) )
|
|
478
|
-
elsif line.start_with?( "branch " )
|
|
479
|
-
current[ :branch ] = line.sub( "branch refs/heads/", "" )
|
|
480
|
-
elsif line == "detached"
|
|
481
|
-
current[ :branch ] = nil
|
|
482
|
-
end
|
|
82
|
+
base = if File.exist?( candidate ) || Dir.exist?( candidate )
|
|
83
|
+
File.realpath( candidate )
|
|
84
|
+
else
|
|
85
|
+
candidate
|
|
483
86
|
end
|
|
484
|
-
entries << current unless current.empty?
|
|
485
|
-
entries
|
|
486
|
-
end
|
|
487
87
|
|
|
488
|
-
|
|
489
|
-
# Falls back to File.expand_path when the path does not exist yet.
|
|
490
|
-
def realpath_safe( path )
|
|
491
|
-
File.realpath( path )
|
|
492
|
-
rescue Errno::ENOENT
|
|
493
|
-
File.expand_path( path )
|
|
88
|
+
missing_segments.empty? ? base : File.join( base, *missing_segments )
|
|
494
89
|
end
|
|
495
90
|
end
|
|
496
91
|
|
|
@@ -5,6 +5,73 @@ module Carson
|
|
|
5
5
|
module GateSupport
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
+
def review_gate_report_for_missing_pr( branch_name: )
|
|
9
|
+
{
|
|
10
|
+
generated_at: Time.now.utc.iso8601,
|
|
11
|
+
branch: branch_name,
|
|
12
|
+
status: "block",
|
|
13
|
+
converged: false,
|
|
14
|
+
wait_seconds: config.review_wait_seconds,
|
|
15
|
+
poll_seconds: config.review_poll_seconds,
|
|
16
|
+
max_polls: config.review_max_polls,
|
|
17
|
+
block_reasons: [ "no pull request found for current branch" ],
|
|
18
|
+
pr: nil,
|
|
19
|
+
unresolved_threads: [],
|
|
20
|
+
actionable_top_level: [],
|
|
21
|
+
unacknowledged_actionable: []
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def review_gate_report_for_pr( owner:, repo:, pr_number:, branch_name:, pr_summary: nil )
|
|
26
|
+
resolved_pr_summary = resolved_review_gate_pr_summary(
|
|
27
|
+
owner: owner,
|
|
28
|
+
repo: repo,
|
|
29
|
+
pr_number: pr_number,
|
|
30
|
+
pr_summary: pr_summary
|
|
31
|
+
)
|
|
32
|
+
pre_snapshot = wait_for_review_warmup( owner: owner, repo: repo, pr_number: pr_number )
|
|
33
|
+
converged = false
|
|
34
|
+
last_snapshot = pre_snapshot
|
|
35
|
+
last_signature = pre_snapshot.nil? ? nil : review_gate_signature( snapshot: pre_snapshot )
|
|
36
|
+
poll_attempts = 0
|
|
37
|
+
|
|
38
|
+
config.review_max_polls.times do |index|
|
|
39
|
+
poll_attempts = index + 1
|
|
40
|
+
snapshot = review_gate_snapshot( owner: owner, repo: repo, pr_number: pr_number )
|
|
41
|
+
last_snapshot = snapshot
|
|
42
|
+
signature = review_gate_signature( snapshot: snapshot )
|
|
43
|
+
puts_verbose "poll_attempt: #{poll_attempts}/#{config.review_max_polls}"
|
|
44
|
+
puts_verbose "latest_activity: #{snapshot.fetch( :latest_activity ) || 'unknown'}"
|
|
45
|
+
puts_verbose "unresolved_threads: #{snapshot.fetch( :unresolved_threads ).count}"
|
|
46
|
+
puts_verbose "unacknowledged_actionable: #{snapshot.fetch( :unacknowledged_actionable ).count}"
|
|
47
|
+
if !last_signature.nil? && signature == last_signature
|
|
48
|
+
converged = true
|
|
49
|
+
puts_verbose "convergence: stable"
|
|
50
|
+
break
|
|
51
|
+
end
|
|
52
|
+
last_signature = signature
|
|
53
|
+
wait_for_review_poll if index < config.review_max_polls - 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
build_review_gate_report(
|
|
57
|
+
branch_name: branch_name,
|
|
58
|
+
pr_summary: resolved_pr_summary,
|
|
59
|
+
snapshot: last_snapshot,
|
|
60
|
+
converged: converged,
|
|
61
|
+
poll_attempts: poll_attempts
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def review_gate_result( report: )
|
|
66
|
+
return { status: :pass, review: :approved, detail: "review gate passed" } if report.fetch( :status ) == "ok"
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
status: :fail,
|
|
70
|
+
review: review_gate_changes_requested?( report: report ) ? :changes_requested : :blocked,
|
|
71
|
+
detail: report.fetch( :block_reasons ).join( "; " )
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
8
75
|
def wait_for_review_warmup( owner:, repo:, pr_number: )
|
|
9
76
|
return unless config.review_wait_seconds.positive?
|
|
10
77
|
quick = review_gate_snapshot( owner: owner, repo: repo, pr_number: pr_number )
|
|
@@ -67,8 +134,11 @@ module Carson
|
|
|
67
134
|
}
|
|
68
135
|
end
|
|
69
136
|
|
|
137
|
+
# GraphQL returns "gemini-code-assist"; REST returns "gemini-code-assist[bot]".
|
|
138
|
+
# Normalise both sides by stripping the [bot] suffix for a consistent match.
|
|
70
139
|
def bot_username?( author: )
|
|
71
|
-
|
|
140
|
+
normalised = author.to_s.downcase.delete_suffix( "[bot]" )
|
|
141
|
+
config.review_bot_usernames.any? { it.downcase.delete_suffix( "[bot]" ) == normalised }
|
|
72
142
|
end
|
|
73
143
|
|
|
74
144
|
def unresolved_thread_entries( details: )
|
|
@@ -166,6 +236,66 @@ module Carson
|
|
|
166
236
|
timestamps.map { parse_time_or_nil( text: it ) }.compact.max&.utc&.iso8601
|
|
167
237
|
end
|
|
168
238
|
|
|
239
|
+
def resolved_review_gate_pr_summary( owner:, repo:, pr_number:, pr_summary: )
|
|
240
|
+
required_keys = %i[number title url state]
|
|
241
|
+
if !pr_summary.nil? && required_keys.all? { |key| pr_summary.key?( key ) && !pr_summary.fetch( key ).to_s.empty? }
|
|
242
|
+
return pr_summary
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
pull_request_summary( owner: owner, repo: repo, pr_number: pr_number )
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def pull_request_summary( owner:, repo:, pr_number: )
|
|
249
|
+
details = pull_request_details( owner: owner, repo: repo, pr_number: pr_number )
|
|
250
|
+
{
|
|
251
|
+
number: details.fetch( :number ),
|
|
252
|
+
title: details.fetch( :title ),
|
|
253
|
+
url: details.fetch( :url ),
|
|
254
|
+
state: details.fetch( :state )
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def build_review_gate_report( branch_name:, pr_summary:, snapshot:, converged:, poll_attempts: )
|
|
259
|
+
{
|
|
260
|
+
generated_at: Time.now.utc.iso8601,
|
|
261
|
+
branch: branch_name,
|
|
262
|
+
status: review_gate_block_reasons( snapshot: snapshot, converged: converged ).empty? ? "ok" : "block",
|
|
263
|
+
converged: converged,
|
|
264
|
+
wait_seconds: config.review_wait_seconds,
|
|
265
|
+
poll_seconds: config.review_poll_seconds,
|
|
266
|
+
max_polls: config.review_max_polls,
|
|
267
|
+
poll_attempts: poll_attempts,
|
|
268
|
+
block_reasons: review_gate_block_reasons( snapshot: snapshot, converged: converged ),
|
|
269
|
+
pr: {
|
|
270
|
+
number: pr_summary.fetch( :number ),
|
|
271
|
+
title: pr_summary.fetch( :title ),
|
|
272
|
+
url: pr_summary.fetch( :url ),
|
|
273
|
+
state: pr_summary.fetch( :state )
|
|
274
|
+
},
|
|
275
|
+
unresolved_threads: snapshot.fetch( :unresolved_threads ),
|
|
276
|
+
actionable_top_level: snapshot.fetch( :actionable_top_level ),
|
|
277
|
+
unacknowledged_actionable: snapshot.fetch( :unacknowledged_actionable )
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def review_gate_block_reasons( snapshot:, converged: )
|
|
282
|
+
reasons = []
|
|
283
|
+
reasons << "review snapshot did not converge within #{config.review_max_polls} polls" unless converged
|
|
284
|
+
if snapshot.fetch( :unresolved_threads ).any?
|
|
285
|
+
reasons << "unresolved review threads remain (#{snapshot.fetch( :unresolved_threads ).count})"
|
|
286
|
+
end
|
|
287
|
+
if snapshot.fetch( :unacknowledged_actionable ).any?
|
|
288
|
+
reasons << "actionable top-level comments/reviews without required disposition (#{snapshot.fetch( :unacknowledged_actionable ).count})"
|
|
289
|
+
end
|
|
290
|
+
reasons
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def review_gate_changes_requested?( report: )
|
|
294
|
+
Array( report.fetch( :unacknowledged_actionable ) ).any? do |entry|
|
|
295
|
+
entry.fetch( :reason ) == "changes_requested_review"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
169
299
|
# Writes review gate artefacts using fixed report names in global report output.
|
|
170
300
|
def write_review_gate_report( report: )
|
|
171
301
|
markdown_path, json_path = report(
|