carson 3.10.2 → 3.10.4

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: d2734c0dc1bc4bdea4f81fe024fce1f92aa303d47eec26c7c9ef5370260a83ef
4
- data.tar.gz: 7f16d9206694d8f448817dc7b9b5e034354ac222f082774b281b8f8a2ac9a482
3
+ metadata.gz: 15dca7c329b421bc534d0ea857d38af70bfe89bad5eb2ec40be9bbe09015e37f
4
+ data.tar.gz: a2a997bf5f9be278a33034155fffa61caa1c28e78c24fa3d8af85e43f72241c0
5
5
  SHA512:
6
- metadata.gz: 3a498a739c2b113e41fa9980a89e5de218520bb3f53b4521d1bc7a706f7a5890a193b6650b724b0671f8b1ec66d4865a94ea2f64ac24328034ffb9b07cefd7df
7
- data.tar.gz: f7db7bc6d8e00580aecd53f7d0e3e35d511a2d39df5a84cff90a98d60ccfed2efa731f5902ee9f362d3afe4b4b4e78e8ae44b6c6b31f2ff545903611e9836e85
6
+ metadata.gz: 8e7f231146222978f1f880a08e16ce99bb75ae3c69899f20d23413bd88b712edc0cc8a21b3b1515437496ef625e079318a62f435dd4634490cafa3ac034c09b9
7
+ data.tar.gz: 2e3ec460ec390a021b48e132597899e5c6d7df5f3d1e667aa29572f0b45de673a076410bf81a6b768b6dd06fb60adec5672278e4cbbd695a5d598688afaecbb2
data/RELEASE.md CHANGED
@@ -5,6 +5,28 @@ 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.4
9
+
10
+ ### What changed
11
+
12
+ - **Worktree remove guards unpushed commits** — `carson worktree remove` now checks for unpushed commits before deleting a worktree. Blocks with recovery guidance (push command or `--force` to override). Prevents accidental destruction of work that exists only locally.
13
+ - **Shared unpushed-commits check** — extracted `check_unpushed_commits` method used by both `worktree done` and `worktree remove`, eliminating code duplication.
14
+ - **Fix resolve path from inside worktrees** — `resolve_worktree_path` now uses `main_worktree_root` instead of `repo_root` for bare-name resolution. Previously, calling `carson worktree remove <name>` from inside a worktree would look in the wrong directory.
15
+
16
+ ### Migration
17
+
18
+ - No breaking changes. `--force` overrides the new unpushed-commits guard.
19
+
20
+ ## 3.10.3
21
+
22
+ ### What changed
23
+
24
+ - **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.
25
+
26
+ ### Migration
27
+
28
+ - No breaking changes. Merge behaviour is unchanged; only the post-merge local branch deletion attempt is removed.
29
+
8
30
  ## 3.10.2
9
31
 
10
32
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.10.2
1
+ 3.10.4
@@ -256,14 +256,16 @@ module Carson
256
256
  end
257
257
 
258
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`.
259
262
  def merge_pr!( number:, result: )
260
263
  method = config.govern_merge_method
261
264
  result[ :merge_method ] = method
262
265
 
263
266
  _, stderr, success, = gh_run(
264
267
  "pr", "merge", number.to_s,
265
- "--#{method}",
266
- "--delete-branch"
268
+ "--#{method}"
267
269
  )
268
270
 
269
271
  if success
@@ -272,7 +274,7 @@ module Carson
272
274
  error_text = stderr.to_s.strip
273
275
  error_text = "merge failed" if error_text.empty?
274
276
  result[ :error ] = error_text
275
- result[ :recovery ] = "gh pr merge #{number} --#{method} --delete-branch"
277
+ result[ :recovery ] = "gh pr merge #{number} --#{method}"
276
278
  EXIT_ERROR
277
279
  end
278
280
  end
@@ -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?
@@ -1,14 +1,15 @@
1
1
  # Safe worktree lifecycle management for coding agents.
2
- # Three operations: create, done (mark completed), remove (batch cleanup).
3
- # The deferred deletion model: worktrees persist after use, cleaned up later.
2
+ # Three operations: create, done (mark completed), remove (full cleanup).
3
+ # Remove guards against unpushed commits and CWD-inside-worktree safe by default.
4
4
  # Supports --json for machine-readable structured output with recovery commands.
5
5
  module Carson
6
6
  class Runtime
7
7
  module Local
8
8
 
9
9
  # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
10
+ # Uses main_worktree_root so this works even when called from inside a worktree.
10
11
  def worktree_create!( name:, json_output: false )
11
- worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
12
+ worktrees_dir = File.join( main_worktree_root, ".claude", "worktrees" )
12
13
  wt_path = File.join( worktrees_dir, name )
13
14
 
14
15
  if Dir.exist?( wt_path )
@@ -83,35 +84,16 @@ module Carson
83
84
  )
84
85
  end
85
86
 
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.
87
+ # Check for unpushed commits using shared guard.
91
88
  branch = worktree_branch( path: resolved_path )
92
- if branch
93
- remote = config.git_remote
94
- remote_ref = "#{remote}/#{branch}"
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
108
- return worktree_finish(
109
- result: { command: "worktree done", status: "block", name: name, branch: branch,
110
- error: "worktree has unpushed commits",
111
- recovery: "git -C #{resolved_path} push #{remote} #{branch}" },
112
- exit_code: EXIT_BLOCK, json_output: json_output
113
- )
114
- end
89
+ unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
90
+ if unpushed
91
+ return worktree_finish(
92
+ result: { command: "worktree done", status: "block", name: name, branch: branch,
93
+ error: unpushed[ :error ],
94
+ recovery: unpushed[ :recovery ] },
95
+ exit_code: EXIT_BLOCK, json_output: json_output
96
+ )
115
97
  end
116
98
 
117
99
  # Clear worktree from session state.
@@ -167,6 +149,21 @@ module Carson
167
149
  branch = worktree_branch( path: resolved_path )
168
150
  puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
169
151
 
152
+ # Safety: refuse if the branch has unpushed commits (unless --force).
153
+ # Prevents accidental destruction of work that exists only locally.
154
+ unless force
155
+ unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
156
+ if unpushed
157
+ return worktree_finish(
158
+ result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
159
+ branch: branch,
160
+ error: unpushed[ :error ],
161
+ recovery: unpushed[ :recovery ] },
162
+ exit_code: EXIT_BLOCK, json_output: json_output
163
+ )
164
+ end
165
+ end
166
+
170
167
  # Step 1: remove the worktree (directory + git registration).
171
168
  rm_args = [ "worktree", "remove" ]
172
169
  rm_args << "--force" if force
@@ -279,6 +276,29 @@ module Carson
279
276
  false
280
277
  end
281
278
 
279
+ # Checks whether a branch has unpushed commits that would be lost on removal.
280
+ # Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
281
+ def check_unpushed_commits( branch:, worktree_path: )
282
+ return nil unless branch
283
+
284
+ remote = config.git_remote
285
+ remote_ref = "#{remote}/#{branch}"
286
+ ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: worktree_path )
287
+ if !ahead_status.success?
288
+ # Remote ref does not exist. Only block if the branch has unique commits vs main.
289
+ unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{config.main_branch}..#{branch}", chdir: worktree_path )
290
+ if unique_status.success? && unique.strip.to_i > 0
291
+ return { error: "branch has not been pushed to #{remote}",
292
+ recovery: "git -C #{worktree_path} push -u #{remote} #{branch}, or use --force to override" }
293
+ end
294
+ elsif ahead.strip.to_i > 0
295
+ return { error: "worktree has unpushed commits",
296
+ recovery: "git -C #{worktree_path} push #{remote} #{branch}, or use --force to override" }
297
+ end
298
+
299
+ nil
300
+ end
301
+
282
302
  # Returns the main (non-worktree) repository root.
283
303
  # Uses git-common-dir to find the shared .git directory, then takes its parent.
284
304
  # Falls back to repo_root if detection fails.
@@ -293,8 +313,9 @@ module Carson
293
313
  # This prevents worktree directories from appearing as untracked files
294
314
  # in the host repository. Uses the local exclude file (never committed)
295
315
  # so the host repo's .gitignore is never touched.
316
+ # Uses main_worktree_root — worktrees have .git as a file, not a directory.
296
317
  def ensure_claude_dir_excluded!
297
- git_dir = File.join( repo_root, ".git" )
318
+ git_dir = File.join( main_worktree_root, ".git" )
298
319
  return unless File.directory?( git_dir )
299
320
 
300
321
  info_dir = File.join( git_dir, "info" )
@@ -312,12 +333,14 @@ module Carson
312
333
  # Resolves a worktree path: if it's a bare name, look under .claude/worktrees/.
313
334
  # Returns the canonical (realpath) form so comparisons against git worktree list succeed,
314
335
  # even when the OS resolves symlinks differently (e.g. /tmp → /private/tmp on macOS).
336
+ # Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
315
337
  def resolve_worktree_path( worktree_path: )
316
338
  if worktree_path.include?( "/" )
317
339
  return realpath_safe( worktree_path )
318
340
  end
319
341
 
320
- candidate = File.join( repo_root, ".claude", "worktrees", worktree_path )
342
+ root = main_worktree_root
343
+ candidate = File.join( root, ".claude", "worktrees", worktree_path )
321
344
  return realpath_safe( candidate ) if Dir.exist?( candidate )
322
345
 
323
346
  realpath_safe( worktree_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.2
4
+ version: 3.10.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang