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 +4 -4
- data/RELEASE.md +22 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/deliver.rb +5 -3
- data/lib/carson/runtime/govern.rb +1 -1
- data/lib/carson/runtime/local/worktree.rb +56 -33
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15dca7c329b421bc534d0ea857d38af70bfe89bad5eb2ec40be9bbe09015e37f
|
|
4
|
+
data.tar.gz: a2a997bf5f9be278a33034155fffa61caa1c28e78c24fa3d8af85e43f72241c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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}
|
|
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 (
|
|
3
|
-
#
|
|
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(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
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
|
-
|
|
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 )
|