carson 3.10.0 → 3.10.2
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/RELEASE.md +25 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/deliver.rb +50 -8
- data/lib/carson/runtime/local/worktree.rb +83 -14
- data/lib/carson/runtime/status.rb +3 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d2734c0dc1bc4bdea4f81fe024fce1f92aa303d47eec26c7c9ef5370260a83ef
|
|
4
|
+
data.tar.gz: 7f16d9206694d8f448817dc7b9b5e034354ac222f082774b281b8f8a2ac9a482
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a498a739c2b113e41fa9980a89e5de218520bb3f53b4521d1bc7a706f7a5890a193b6650b724b0671f8b1ec66d4865a94ea2f64ac24328034ffb9b07cefd7df
|
|
7
|
+
data.tar.gz: f7db7bc6d8e00580aecd53f7d0e3e35d511a2d39df5a84cff90a98d60ccfed2efa731f5902ee9f362d3afe4b4b4e78e8ae44b6c6b31f2ff545903611e9836e85
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,31 @@ Release-note scope rule:
|
|
|
5
5
|
- `RELEASE.md` records only version deltas, breaking changes, and migration actions.
|
|
6
6
|
- Operational usage guides live in `MANUAL.md` and `API.md`.
|
|
7
7
|
|
|
8
|
+
## 3.10.2
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **Path normalisation** — all worktree path comparisons now use `File.realpath` to handle symlink differences (e.g. `/tmp` → `/private/tmp` on macOS). Fixes worktree recognition failures in `worktree done`, `worktree remove`, and `status` where Git returns canonical paths that differ from Carson's internal paths.
|
|
13
|
+
- **deliver --merge post-merge sync** — `sync_after_merge!` now pulls into the main worktree via `git -C` instead of attempting `git checkout main` (which always fails inside a feature worktree). Also adds a review gate check before merge (blocks on `CHANGES_REQUESTED`), marks the worktree done in session state after merge, and records sync result in structured output.
|
|
14
|
+
- **Worktree repo pollution** — `worktree create` now auto-adds `.claude/` to `.git/info/exclude` (local-only, never committed) so worktree directories do not appear as untracked files in the host repository. Idempotent and outsider-safe.
|
|
15
|
+
- **Worktree done push guard** — `worktree done` now blocks when a branch has unique unpushed commits and no remote ref exists. Previously the guard silently passed due to a `Process::Status` truthiness bug (`Open3.capture3` returns a Status object that is always truthy; now uses `.success?`). Branches with no unique commits (just created, no work done) are allowed through.
|
|
16
|
+
|
|
17
|
+
### UX
|
|
18
|
+
|
|
19
|
+
- `deliver --merge --json` output now includes `review`, `synced`, and optionally `sync_error` fields.
|
|
20
|
+
- `worktree done` error messages distinguish "branch has not been pushed" from "worktree has unpushed commits", with specific recovery commands for each.
|
|
21
|
+
- Host repositories stay clean after worktree creation — no more `?? .claude/` in `git status`.
|
|
22
|
+
|
|
23
|
+
### Migration
|
|
24
|
+
|
|
25
|
+
- No breaking changes. All fixes are backwards-compatible improvements to existing behaviour.
|
|
26
|
+
|
|
27
|
+
## 3.10.1
|
|
28
|
+
|
|
29
|
+
### What changed
|
|
30
|
+
|
|
31
|
+
- **CWD guard recovery points to main worktree** — the recovery command in the CWD safety block now uses `git rev-parse --git-common-dir` to find the main repository root, not the current worktree's root. Previously, when invoked from inside a worktree, the recovery command would `cd` back to the worktree itself instead of the main repo.
|
|
32
|
+
|
|
8
33
|
## 3.10.0 — CWD Safety Guard
|
|
9
34
|
|
|
10
35
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.10.
|
|
1
|
+
3.10.2
|
|
@@ -51,7 +51,7 @@ module Carson
|
|
|
51
51
|
|
|
52
52
|
case ci_status
|
|
53
53
|
when :pass
|
|
54
|
-
# Continue to
|
|
54
|
+
# Continue to review gate.
|
|
55
55
|
when :pending
|
|
56
56
|
result[ :recovery ] = "gh pr checks #{pr_number} --watch && carson deliver --merge"
|
|
57
57
|
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
@@ -63,14 +63,26 @@ module Carson
|
|
|
63
63
|
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# Step 4:
|
|
66
|
+
# Step 4: check review gate — block if changes are requested.
|
|
67
|
+
review = check_pr_review( number: pr_number )
|
|
68
|
+
result[ :review ] = review.to_s
|
|
69
|
+
if review == :changes_requested
|
|
70
|
+
result[ :error ] = "review changes requested on PR ##{pr_number}"
|
|
71
|
+
result[ :recovery ] = "address review comments, push, then `carson deliver --merge`"
|
|
72
|
+
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Step 5: merge.
|
|
67
76
|
merge_exit = merge_pr!( number: pr_number, result: result )
|
|
68
77
|
return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
|
|
69
78
|
|
|
70
79
|
result[ :merged ] = true
|
|
71
80
|
|
|
72
|
-
# Step
|
|
73
|
-
|
|
81
|
+
# Step 6: mark worktree done in session state.
|
|
82
|
+
update_session( worktree: :clear )
|
|
83
|
+
|
|
84
|
+
# Step 7: sync main in the main worktree.
|
|
85
|
+
sync_after_merge!( remote: remote, main: main, result: result )
|
|
74
86
|
|
|
75
87
|
deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
76
88
|
end
|
|
@@ -225,6 +237,24 @@ module Carson
|
|
|
225
237
|
:pass
|
|
226
238
|
end
|
|
227
239
|
|
|
240
|
+
# Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
|
|
241
|
+
def check_pr_review( number: )
|
|
242
|
+
stdout, _, success, = gh_run(
|
|
243
|
+
"pr", "view", number.to_s,
|
|
244
|
+
"--json", "reviewDecision"
|
|
245
|
+
)
|
|
246
|
+
return :none unless success
|
|
247
|
+
|
|
248
|
+
data = JSON.parse( stdout ) rescue {}
|
|
249
|
+
decision = data[ "reviewDecision" ].to_s.strip.upcase
|
|
250
|
+
case decision
|
|
251
|
+
when "APPROVED" then :approved
|
|
252
|
+
when "CHANGES_REQUESTED" then :changes_requested
|
|
253
|
+
when "REVIEW_REQUIRED" then :review_required
|
|
254
|
+
else :none
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
228
258
|
# Merges the PR using the configured merge method.
|
|
229
259
|
def merge_pr!( number:, result: )
|
|
230
260
|
method = config.govern_merge_method
|
|
@@ -248,10 +278,22 @@ module Carson
|
|
|
248
278
|
end
|
|
249
279
|
|
|
250
280
|
# Syncs main after a successful merge.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
281
|
+
# Pulls into the main worktree directly — does not attempt checkout,
|
|
282
|
+
# because checkout would fail when running inside a feature worktree
|
|
283
|
+
# (main is already checked out in the main tree).
|
|
284
|
+
def sync_after_merge!( remote:, main:, result: )
|
|
285
|
+
main_root = main_worktree_root
|
|
286
|
+
_, pull_stderr, pull_success, = Open3.capture3(
|
|
287
|
+
"git", "-C", main_root, "pull", "--ff-only", remote, main
|
|
288
|
+
)
|
|
289
|
+
if pull_success
|
|
290
|
+
result[ :synced ] = true
|
|
291
|
+
puts_verbose "synced #{main} in #{main_root} from #{remote}"
|
|
292
|
+
else
|
|
293
|
+
result[ :synced ] = false
|
|
294
|
+
result[ :sync_error ] = pull_stderr.to_s.strip
|
|
295
|
+
puts_verbose "sync failed: #{pull_stderr.to_s.strip}"
|
|
296
|
+
end
|
|
255
297
|
end
|
|
256
298
|
end
|
|
257
299
|
|
|
@@ -23,6 +23,10 @@ module Carson
|
|
|
23
23
|
# Determine the base branch (main branch from config).
|
|
24
24
|
base = config.main_branch
|
|
25
25
|
|
|
26
|
+
# Ensure .claude/ is excluded from git status in the host repository.
|
|
27
|
+
# Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
|
|
28
|
+
ensure_claude_dir_excluded!
|
|
29
|
+
|
|
26
30
|
# Create the worktree with a new branch based on the main branch.
|
|
27
31
|
FileUtils.mkdir_p( worktrees_dir )
|
|
28
32
|
_, wt_stderr, wt_success, = git_run( "worktree", "add", wt_path, "-b", name, base )
|
|
@@ -69,8 +73,8 @@ module Carson
|
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
# Check for uncommitted changes in the worktree.
|
|
72
|
-
wt_status, _,
|
|
73
|
-
if
|
|
76
|
+
wt_status, _, status_result, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
|
|
77
|
+
if status_result.success? && !wt_status.strip.empty?
|
|
74
78
|
return worktree_finish(
|
|
75
79
|
result: { command: "worktree done", status: "block", name: name,
|
|
76
80
|
error: "worktree has uncommitted changes",
|
|
@@ -80,12 +84,27 @@ module Carson
|
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# Check for unpushed commits.
|
|
87
|
+
# If the remote branch does not exist, check whether the branch has unique commits
|
|
88
|
+
# versus the main branch. If it does, block — the work exists only locally.
|
|
89
|
+
# If the branch has no unique commits (just created, no work done), allow.
|
|
90
|
+
# Note: Open3.capture3 returns Process::Status (always truthy), so .success? is required.
|
|
83
91
|
branch = worktree_branch( path: resolved_path )
|
|
84
92
|
if branch
|
|
85
93
|
remote = config.git_remote
|
|
86
94
|
remote_ref = "#{remote}/#{branch}"
|
|
87
|
-
ahead, _,
|
|
88
|
-
if
|
|
95
|
+
ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path )
|
|
96
|
+
if !ahead_status.success?
|
|
97
|
+
# Remote ref does not exist. Only block if the branch has unique commits vs main.
|
|
98
|
+
unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{config.main_branch}..#{branch}", chdir: resolved_path )
|
|
99
|
+
if unique_status.success? && unique.strip.to_i > 0
|
|
100
|
+
return worktree_finish(
|
|
101
|
+
result: { command: "worktree done", status: "block", name: name, branch: branch,
|
|
102
|
+
error: "branch has not been pushed to #{remote}",
|
|
103
|
+
recovery: "git -C #{resolved_path} push -u #{remote} #{branch}" },
|
|
104
|
+
exit_code: EXIT_BLOCK, json_output: json_output
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
elsif ahead.strip.to_i > 0
|
|
89
108
|
return worktree_finish(
|
|
90
109
|
result: { command: "worktree done", status: "block", name: name, branch: branch,
|
|
91
110
|
error: "worktree has unpushed commits",
|
|
@@ -136,10 +155,11 @@ module Carson
|
|
|
136
155
|
# Safety: refuse if the caller's shell CWD is inside the worktree.
|
|
137
156
|
# Removing a directory while a shell is inside it kills the shell permanently.
|
|
138
157
|
if cwd_inside_worktree?( worktree_path: resolved_path )
|
|
158
|
+
safe_root = main_worktree_root
|
|
139
159
|
return worktree_finish(
|
|
140
160
|
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
141
161
|
error: "current working directory is inside this worktree",
|
|
142
|
-
recovery: "cd #{
|
|
162
|
+
recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}" },
|
|
143
163
|
exit_code: EXIT_BLOCK, json_output: json_output
|
|
144
164
|
)
|
|
145
165
|
end
|
|
@@ -249,36 +269,77 @@ module Carson
|
|
|
249
269
|
# Returns true when the process CWD is inside the given worktree path.
|
|
250
270
|
# This detects the most common session-crash scenario: removing a worktree
|
|
251
271
|
# while the caller's shell is inside it.
|
|
272
|
+
# Uses realpath on both sides to handle symlink differences (e.g. /tmp vs /private/tmp).
|
|
252
273
|
def cwd_inside_worktree?( worktree_path: )
|
|
253
|
-
cwd = Dir.pwd
|
|
254
|
-
|
|
255
|
-
|
|
274
|
+
cwd = realpath_safe( Dir.pwd )
|
|
275
|
+
wt = realpath_safe( worktree_path )
|
|
276
|
+
normalised_wt = File.join( wt, "" )
|
|
277
|
+
cwd == wt || cwd.start_with?( normalised_wt )
|
|
256
278
|
rescue StandardError
|
|
257
279
|
false
|
|
258
280
|
end
|
|
259
281
|
|
|
282
|
+
# Returns the main (non-worktree) repository root.
|
|
283
|
+
# Uses git-common-dir to find the shared .git directory, then takes its parent.
|
|
284
|
+
# Falls back to repo_root if detection fails.
|
|
285
|
+
def main_worktree_root
|
|
286
|
+
common_dir, _, success, = git_run( "rev-parse", "--path-format=absolute", "--git-common-dir" )
|
|
287
|
+
return File.dirname( common_dir.strip ) if success && !common_dir.strip.empty?
|
|
288
|
+
|
|
289
|
+
repo_root
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Adds .claude/ to .git/info/exclude if not already present.
|
|
293
|
+
# This prevents worktree directories from appearing as untracked files
|
|
294
|
+
# in the host repository. Uses the local exclude file (never committed)
|
|
295
|
+
# so the host repo's .gitignore is never touched.
|
|
296
|
+
def ensure_claude_dir_excluded!
|
|
297
|
+
git_dir = File.join( repo_root, ".git" )
|
|
298
|
+
return unless File.directory?( git_dir )
|
|
299
|
+
|
|
300
|
+
info_dir = File.join( git_dir, "info" )
|
|
301
|
+
exclude_path = File.join( info_dir, "exclude" )
|
|
302
|
+
|
|
303
|
+
FileUtils.mkdir_p( info_dir )
|
|
304
|
+
existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
|
|
305
|
+
return if existing.lines.any? { |line| line.strip == ".claude/" }
|
|
306
|
+
|
|
307
|
+
File.open( exclude_path, "a" ) { |f| f.puts ".claude/" }
|
|
308
|
+
rescue StandardError
|
|
309
|
+
# Best-effort — do not block worktree creation if exclude fails.
|
|
310
|
+
end
|
|
311
|
+
|
|
260
312
|
# Resolves a worktree path: if it's a bare name, look under .claude/worktrees/.
|
|
313
|
+
# Returns the canonical (realpath) form so comparisons against git worktree list succeed,
|
|
314
|
+
# even when the OS resolves symlinks differently (e.g. /tmp → /private/tmp on macOS).
|
|
261
315
|
def resolve_worktree_path( worktree_path: )
|
|
262
|
-
|
|
316
|
+
if worktree_path.include?( "/" )
|
|
317
|
+
return realpath_safe( worktree_path )
|
|
318
|
+
end
|
|
263
319
|
|
|
264
320
|
candidate = File.join( repo_root, ".claude", "worktrees", worktree_path )
|
|
265
|
-
return candidate if Dir.exist?( candidate )
|
|
321
|
+
return realpath_safe( candidate ) if Dir.exist?( candidate )
|
|
266
322
|
|
|
267
|
-
|
|
323
|
+
realpath_safe( worktree_path )
|
|
268
324
|
end
|
|
269
325
|
|
|
270
326
|
# Returns true if the path is a registered git worktree.
|
|
327
|
+
# Compares using realpath to handle symlink differences.
|
|
271
328
|
def worktree_registered?( path: )
|
|
272
|
-
|
|
329
|
+
canonical = realpath_safe( path )
|
|
330
|
+
worktree_list.any? { |wt| wt.fetch( :path ) == canonical }
|
|
273
331
|
end
|
|
274
332
|
|
|
275
333
|
# Returns the branch name checked out in a worktree, or nil.
|
|
334
|
+
# Compares using realpath to handle symlink differences.
|
|
276
335
|
def worktree_branch( path: )
|
|
277
|
-
|
|
336
|
+
canonical = realpath_safe( path )
|
|
337
|
+
entry = worktree_list.find { |wt| wt.fetch( :path ) == canonical }
|
|
278
338
|
entry&.fetch( :branch, nil )
|
|
279
339
|
end
|
|
280
340
|
|
|
281
341
|
# Parses `git worktree list --porcelain` into structured entries.
|
|
342
|
+
# Normalises paths with realpath so comparisons work across symlink differences.
|
|
282
343
|
def worktree_list
|
|
283
344
|
output = git_capture!( "worktree", "list", "--porcelain" )
|
|
284
345
|
entries = []
|
|
@@ -289,7 +350,7 @@ module Carson
|
|
|
289
350
|
entries << current unless current.empty?
|
|
290
351
|
current = {}
|
|
291
352
|
elsif line.start_with?( "worktree " )
|
|
292
|
-
current[ :path ] = line.sub( "worktree ", "" )
|
|
353
|
+
current[ :path ] = realpath_safe( line.sub( "worktree ", "" ) )
|
|
293
354
|
elsif line.start_with?( "branch " )
|
|
294
355
|
current[ :branch ] = line.sub( "branch refs/heads/", "" )
|
|
295
356
|
elsif line == "detached"
|
|
@@ -299,6 +360,14 @@ module Carson
|
|
|
299
360
|
entries << current unless current.empty?
|
|
300
361
|
entries
|
|
301
362
|
end
|
|
363
|
+
|
|
364
|
+
# Resolves a path to its canonical form, tolerating non-existent paths.
|
|
365
|
+
# Falls back to File.expand_path when the path does not exist yet.
|
|
366
|
+
def realpath_safe( path )
|
|
367
|
+
File.realpath( path )
|
|
368
|
+
rescue Errno::ENOENT
|
|
369
|
+
File.expand_path( path )
|
|
370
|
+
end
|
|
302
371
|
end
|
|
303
372
|
|
|
304
373
|
include Local
|
|
@@ -88,7 +88,9 @@ module Carson
|
|
|
88
88
|
ownership = build_worktree_ownership( sessions: sessions )
|
|
89
89
|
|
|
90
90
|
# Filter out the main worktree (the repository root itself).
|
|
91
|
-
|
|
91
|
+
# Use realpath for comparison — git returns canonical paths that may differ from repo_root.
|
|
92
|
+
canonical_root = realpath_safe( repo_root )
|
|
93
|
+
entries.reject { |wt| wt.fetch( :path ) == canonical_root }.map do |wt|
|
|
92
94
|
name = File.basename( wt.fetch( :path ) )
|
|
93
95
|
info = {
|
|
94
96
|
path: wt.fetch( :path ),
|