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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0a28061ca9078e4ba0cf70626b209da67a9b85b6af31414d2a98e7f7e7abd36
4
- data.tar.gz: de84e7e100d34a35ca9d844e34235eaf2e5b9dcf5071d2b651f28ee4bf83d3e6
3
+ metadata.gz: d2734c0dc1bc4bdea4f81fe024fce1f92aa303d47eec26c7c9ef5370260a83ef
4
+ data.tar.gz: 7f16d9206694d8f448817dc7b9b5e034354ac222f082774b281b8f8a2ac9a482
5
5
  SHA512:
6
- metadata.gz: 42ac93cfe9c110636f1327e2cf0a1cbd1fc4ad1cb258d745e3c487984153f95e3ec5ebe70016214d8f8a63de3ee13c8a248afedd6a6710277019011f5cf995d2
7
- data.tar.gz: a64696f982dbe5947a333cf0fd2c94744a739b26dde0a60593f322a368ea9a6b371e6901007c762c012a810fbcb2702fcfc23d8b9543c51ffa966d4a83b97b6f
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.0
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",
@@ -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 #{repo_root} && carson worktree remove #{File.basename( resolved_path )}" },
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
- normalised_wt = File.join( worktree_path, "" )
255
- 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 )
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
- return File.expand_path( worktree_path ) if worktree_path.include?( "/" )
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
- File.expand_path( worktree_path )
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
- worktree_list.any? { |wt| wt.fetch( :path ) == path }
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
- entry = worktree_list.find { |wt| wt.fetch( :path ) == path }
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
- 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.0
4
+ version: 3.10.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang