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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e11a0b3de1e579e809455fca256a40882a226ae914e296cf0a670f0268c6ee3
4
- data.tar.gz: 2b6bfb9124d69bec88912fae936d7394d1bb77a5e37fed2653e026fff14a531b
3
+ metadata.gz: d2734c0dc1bc4bdea4f81fe024fce1f92aa303d47eec26c7c9ef5370260a83ef
4
+ data.tar.gz: 7f16d9206694d8f448817dc7b9b5e034354ac222f082774b281b8f8a2ac9a482
5
5
  SHA512:
6
- metadata.gz: b2126c426ece435a94905532f7e7776cc873debb62aa57ba5267ec830a5995da330db79ebe28c5939835c1e15084132d60f0084abd7bee6e1d7780ec35fc288d
7
- data.tar.gz: 5d4d9442043aa1abb923714ceabacae0edadba4b55b870a85cf2d0697364d92951299e86d85641202671ec0ceddd30407e52d8d2db8a65bd55782214ab45aa8f
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
1
+ 3.10.2
@@ -51,7 +51,7 @@ module Carson
51
51
 
52
52
  case ci_status
53
53
  when :pass
54
- # Continue to merge.
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: merge.
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 5: sync main.
73
- sync_after_merge!( remote: remote, main: main )
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
- def sync_after_merge!( remote:, main: )
252
- git_run( "checkout", main )
253
- git_run( "pull", remote, main )
254
- puts_verbose "synced #{main} from #{remote}"
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, _, status_success, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
73
- if status_success && !wt_status.strip.empty?
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, _, ahead_ok, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path )
88
- if ahead_ok && ahead.strip.to_i > 0
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
- normalised_wt = File.join( worktree_path, "" )
256
- cwd == worktree_path || cwd.start_with?( normalised_wt )
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
- return File.expand_path( worktree_path ) if worktree_path.include?( "/" )
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
- File.expand_path( worktree_path )
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
- worktree_list.any? { |wt| wt.fetch( :path ) == path }
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
- entry = worktree_list.find { |wt| wt.fetch( :path ) == path }
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
- entries.reject { |wt| wt.fetch( :path ) == repo_root }.map do |wt|
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 ),
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.10.1
4
+ version: 3.10.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang