carson 3.10.1 → 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 +19 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/deliver.rb +50 -8
- data/lib/carson/runtime/local/worktree.rb +71 -13
- 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,25 @@ 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
|
+
|
|
8
27
|
## 3.10.1
|
|
9
28
|
|
|
10
29
|
### 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",
|
|
@@ -250,10 +269,12 @@ module Carson
|
|
|
250
269
|
# Returns true when the process CWD is inside the given worktree path.
|
|
251
270
|
# This detects the most common session-crash scenario: removing a worktree
|
|
252
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).
|
|
253
273
|
def cwd_inside_worktree?( worktree_path: )
|
|
254
|
-
cwd = Dir.pwd
|
|
255
|
-
|
|
256
|
-
|
|
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 )
|
|
257
278
|
rescue StandardError
|
|
258
279
|
false
|
|
259
280
|
end
|
|
@@ -268,28 +289,57 @@ module Carson
|
|
|
268
289
|
repo_root
|
|
269
290
|
end
|
|
270
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
|
+
|
|
271
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).
|
|
272
315
|
def resolve_worktree_path( worktree_path: )
|
|
273
|
-
|
|
316
|
+
if worktree_path.include?( "/" )
|
|
317
|
+
return realpath_safe( worktree_path )
|
|
318
|
+
end
|
|
274
319
|
|
|
275
320
|
candidate = File.join( repo_root, ".claude", "worktrees", worktree_path )
|
|
276
|
-
return candidate if Dir.exist?( candidate )
|
|
321
|
+
return realpath_safe( candidate ) if Dir.exist?( candidate )
|
|
277
322
|
|
|
278
|
-
|
|
323
|
+
realpath_safe( worktree_path )
|
|
279
324
|
end
|
|
280
325
|
|
|
281
326
|
# Returns true if the path is a registered git worktree.
|
|
327
|
+
# Compares using realpath to handle symlink differences.
|
|
282
328
|
def worktree_registered?( path: )
|
|
283
|
-
|
|
329
|
+
canonical = realpath_safe( path )
|
|
330
|
+
worktree_list.any? { |wt| wt.fetch( :path ) == canonical }
|
|
284
331
|
end
|
|
285
332
|
|
|
286
333
|
# Returns the branch name checked out in a worktree, or nil.
|
|
334
|
+
# Compares using realpath to handle symlink differences.
|
|
287
335
|
def worktree_branch( path: )
|
|
288
|
-
|
|
336
|
+
canonical = realpath_safe( path )
|
|
337
|
+
entry = worktree_list.find { |wt| wt.fetch( :path ) == canonical }
|
|
289
338
|
entry&.fetch( :branch, nil )
|
|
290
339
|
end
|
|
291
340
|
|
|
292
341
|
# Parses `git worktree list --porcelain` into structured entries.
|
|
342
|
+
# Normalises paths with realpath so comparisons work across symlink differences.
|
|
293
343
|
def worktree_list
|
|
294
344
|
output = git_capture!( "worktree", "list", "--porcelain" )
|
|
295
345
|
entries = []
|
|
@@ -300,7 +350,7 @@ module Carson
|
|
|
300
350
|
entries << current unless current.empty?
|
|
301
351
|
current = {}
|
|
302
352
|
elsif line.start_with?( "worktree " )
|
|
303
|
-
current[ :path ] = line.sub( "worktree ", "" )
|
|
353
|
+
current[ :path ] = realpath_safe( line.sub( "worktree ", "" ) )
|
|
304
354
|
elsif line.start_with?( "branch " )
|
|
305
355
|
current[ :branch ] = line.sub( "branch refs/heads/", "" )
|
|
306
356
|
elsif line == "detached"
|
|
@@ -310,6 +360,14 @@ module Carson
|
|
|
310
360
|
entries << current unless current.empty?
|
|
311
361
|
entries
|
|
312
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
|
|
313
371
|
end
|
|
314
372
|
|
|
315
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 ),
|