carson 3.10.3 → 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: 2bbb3e13e1c89593777ce820ddcf6ef96502693aaa1c2d41303e95cc374a46d9
4
- data.tar.gz: 402e3a4b6152547e7b328c02686aeea4780a329cc61a187e75ec256de8355244
3
+ metadata.gz: 15dca7c329b421bc534d0ea857d38af70bfe89bad5eb2ec40be9bbe09015e37f
4
+ data.tar.gz: a2a997bf5f9be278a33034155fffa61caa1c28e78c24fa3d8af85e43f72241c0
5
5
  SHA512:
6
- metadata.gz: d415ae9589a2a65587709a3adf079f112c5489890c68ffe42b295b23527dca980669e8433ec3a531f77628dd07099d7368d5c055817463627e5d9aa8710ec61a
7
- data.tar.gz: 45d641154e946ee1f1f37e1d6b3a61971cab37c34433e6fd4ecbc6f44f0691b01268213f1664416061db5cedcf8f306b58698043667613f345005dd3f852050d
6
+ metadata.gz: 8e7f231146222978f1f880a08e16ce99bb75ae3c69899f20d23413bd88b712edc0cc8a21b3b1515437496ef625e079318a62f435dd4634490cafa3ac034c09b9
7
+ data.tar.gz: 2e3ec460ec390a021b48e132597899e5c6d7df5f3d1e667aa29572f0b45de673a076410bf81a6b768b6dd06fb60adec5672278e4cbbd695a5d598688afaecbb2
data/RELEASE.md CHANGED
@@ -5,6 +5,18 @@ 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
+
8
20
  ## 3.10.3
9
21
 
10
22
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.10.3
1
+ 3.10.4
@@ -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.3
4
+ version: 3.10.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang