carson 3.10.1 → 3.10.3

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: 2bbb3e13e1c89593777ce820ddcf6ef96502693aaa1c2d41303e95cc374a46d9
4
+ data.tar.gz: 402e3a4b6152547e7b328c02686aeea4780a329cc61a187e75ec256de8355244
5
5
  SHA512:
6
- metadata.gz: b2126c426ece435a94905532f7e7776cc873debb62aa57ba5267ec830a5995da330db79ebe28c5939835c1e15084132d60f0084abd7bee6e1d7780ec35fc288d
7
- data.tar.gz: 5d4d9442043aa1abb923714ceabacae0edadba4b55b870a85cf2d0697364d92951299e86d85641202671ec0ceddd30407e52d8d2db8a65bd55782214ab45aa8f
6
+ metadata.gz: d415ae9589a2a65587709a3adf079f112c5489890c68ffe42b295b23527dca980669e8433ec3a531f77628dd07099d7368d5c055817463627e5d9aa8710ec61a
7
+ data.tar.gz: 45d641154e946ee1f1f37e1d6b3a61971cab37c34433e6fd4ecbc6f44f0691b01268213f1664416061db5cedcf8f306b58698043667613f345005dd3f852050d
data/RELEASE.md CHANGED
@@ -5,6 +5,35 @@ 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.3
9
+
10
+ ### What changed
11
+
12
+ - **Drop `--delete-branch` from PR merge** — `carson deliver --merge` and `carson govern` no longer pass `--delete-branch` to `gh pr merge`. The flag causes `gh` to attempt switching the local checkout to `main` after deleting the branch, which always fails inside a worktree where `main` is already checked out in the main working tree. Branch cleanup is deferred to `carson prune`, which already handles this correctly.
13
+
14
+ ### Migration
15
+
16
+ - No breaking changes. Merge behaviour is unchanged; only the post-merge local branch deletion attempt is removed.
17
+
18
+ ## 3.10.2
19
+
20
+ ### What changed
21
+
22
+ - **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.
23
+ - **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.
24
+ - **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.
25
+ - **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.
26
+
27
+ ### UX
28
+
29
+ - `deliver --merge --json` output now includes `review`, `synced`, and optionally `sync_error` fields.
30
+ - `worktree done` error messages distinguish "branch has not been pushed" from "worktree has unpushed commits", with specific recovery commands for each.
31
+ - Host repositories stay clean after worktree creation — no more `?? .claude/` in `git status`.
32
+
33
+ ### Migration
34
+
35
+ - No breaking changes. All fixes are backwards-compatible improvements to existing behaviour.
36
+
8
37
  ## 3.10.1
9
38
 
10
39
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.10.1
1
+ 3.10.3
@@ -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,15 +237,35 @@ 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.
259
+ # Deliberately omits --delete-branch: gh tries to switch the local
260
+ # checkout to main afterwards, which fails inside a worktree where
261
+ # main is already checked out. Branch cleanup deferred to `carson prune`.
229
262
  def merge_pr!( number:, result: )
230
263
  method = config.govern_merge_method
231
264
  result[ :merge_method ] = method
232
265
 
233
266
  _, stderr, success, = gh_run(
234
267
  "pr", "merge", number.to_s,
235
- "--#{method}",
236
- "--delete-branch"
268
+ "--#{method}"
237
269
  )
238
270
 
239
271
  if success
@@ -242,16 +274,28 @@ module Carson
242
274
  error_text = stderr.to_s.strip
243
275
  error_text = "merge failed" if error_text.empty?
244
276
  result[ :error ] = error_text
245
- result[ :recovery ] = "gh pr merge #{number} --#{method} --delete-branch"
277
+ result[ :recovery ] = "gh pr merge #{number} --#{method}"
246
278
  EXIT_ERROR
247
279
  end
248
280
  end
249
281
 
250
282
  # 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}"
283
+ # Pulls into the main worktree directly — does not attempt checkout,
284
+ # because checkout would fail when running inside a feature worktree
285
+ # (main is already checked out in the main tree).
286
+ def sync_after_merge!( remote:, main:, result: )
287
+ main_root = main_worktree_root
288
+ _, pull_stderr, pull_success, = Open3.capture3(
289
+ "git", "-C", main_root, "pull", "--ff-only", remote, main
290
+ )
291
+ if pull_success
292
+ result[ :synced ] = true
293
+ puts_verbose "synced #{main} in #{main_root} from #{remote}"
294
+ else
295
+ result[ :synced ] = false
296
+ result[ :sync_error ] = pull_stderr.to_s.strip
297
+ puts_verbose "sync failed: #{pull_stderr.to_s.strip}"
298
+ end
255
299
  end
256
300
  end
257
301
 
@@ -277,6 +277,7 @@ module Carson
277
277
  end
278
278
 
279
279
  # Merges a PR that has passed all gates.
280
+ # Omits --delete-branch (fails inside worktrees). Cleanup via `carson prune`.
280
281
  def merge_if_ready!( pr:, repo_path: )
281
282
  unless config.govern_auto_merge
282
283
  puts_line " merge authority disabled; skipping merge"
@@ -288,7 +289,6 @@ module Carson
288
289
  stdout_text, stderr_text, status = Open3.capture3(
289
290
  "gh", "pr", "merge", number.to_s,
290
291
  "--#{method}",
291
- "--delete-branch",
292
292
  chdir: repo_path
293
293
  )
294
294
  if status.success?
@@ -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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang