carson 3.28.0 → 3.29.0
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/.github/workflows/carson_policy.yml +2 -2
- data/API.md +2 -2
- data/MANUAL.md +10 -8
- data/RELEASE.md +13 -0
- data/VERSION +1 -1
- data/lib/carson/ledger.rb +5 -0
- data/lib/carson/runtime/abandon.rb +12 -9
- data/lib/carson/runtime/govern.rb +55 -19
- data/lib/carson/runtime/housekeep.rb +5 -9
- data/lib/carson/runtime/local/merge_proof.rb +18 -0
- data/lib/carson/runtime/local/worktree.rb +2 -2
- data/lib/carson/runtime/loop_runner.rb +90 -0
- data/lib/carson/runtime.rb +9 -2
- data/lib/carson/worktree.rb +20 -9
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d026cfc1581b568aaa9459ab21faf0d4f52d1026995b5ce62f21dca0072853a
|
|
4
|
+
data.tar.gz: '08bdaaebab6ccaf1dac6768057f55acf3833ddbe903001b2ca3934551f280bae'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 68d7fd88800a7a2756466ae88e3be1baae2df66b25d285cd54b676b6506d1ee86c69a110bf4f040c8259fc9dc8aa623120b0150b4a73c3081460c080940bfb2b
|
|
7
|
+
data.tar.gz: 35403e45bad7632031bbc9c56a8aece6e7b3dc7eafa1c36c4f183bc7c6271beb1b39d4b35985373cb9be0baee3b8cc58491920bb2f63dcf852fb20c4198c33ed
|
|
@@ -27,13 +27,13 @@ jobs:
|
|
|
27
27
|
|
|
28
28
|
steps:
|
|
29
29
|
- name: Checkout host repository
|
|
30
|
-
uses: actions/checkout@
|
|
30
|
+
uses: actions/checkout@v6
|
|
31
31
|
with:
|
|
32
32
|
path: host
|
|
33
33
|
fetch-depth: 0
|
|
34
34
|
|
|
35
35
|
- name: Checkout Carson runtime
|
|
36
|
-
uses: actions/checkout@
|
|
36
|
+
uses: actions/checkout@v6
|
|
37
37
|
with:
|
|
38
38
|
repository: wanghailei/carson
|
|
39
39
|
ref: ${{ inputs.carson_ref }}
|
data/API.md
CHANGED
|
@@ -52,7 +52,7 @@ All batch commands operate across every governed repository registered in `gover
|
|
|
52
52
|
| `carson template check --all` | Read-only template drift detection across all governed repos. |
|
|
53
53
|
| `carson housekeep --all [--loop SECONDS]` | Attempt sync, then reap worktrees with strong abandonment evidence, reconcile integrated delivery worktree records from the ledger, and prune across all governed repos. Safe cleanup still runs when sync is blocked. |
|
|
54
54
|
|
|
55
|
-
`--loop SECONDS` runs the housekeep cycle continuously, sleeping SECONDS between cycles. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` with a cycle count summary.
|
|
55
|
+
`--loop SECONDS` runs the housekeep cycle continuously, sleeping SECONDS between cycles. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` or `SIGTERM` with a cycle count summary.
|
|
56
56
|
|
|
57
57
|
### Govern commands
|
|
58
58
|
|
|
@@ -60,7 +60,7 @@ All batch commands operate across every governed repository registered in `gover
|
|
|
60
60
|
|---|---|
|
|
61
61
|
| `carson govern [--dry-run] [--json] [--loop SECONDS]` | Portfolio-level delivery oversight: assess active deliveries, integrate ready branches, dispatch revisions, and escalate blocked work. Live integrated rows include merge proof. |
|
|
62
62
|
|
|
63
|
-
`--loop SECONDS` runs the govern cycle continuously, sleeping SECONDS between cycles. The loop isolates errors per cycle — a single failing cycle does not stop the daemon. `Ctrl-C` cleanly exits with a cycle count summary. SECONDS must be a positive integer.
|
|
63
|
+
`--loop SECONDS` runs the govern cycle continuously, sleeping SECONDS between cycles. The loop isolates errors per cycle — a single failing cycle does not stop the daemon. `Ctrl-C` or `SIGTERM` cleanly exits with a cycle count summary. SECONDS must be a positive integer.
|
|
64
64
|
|
|
65
65
|
Governed integration is fixed to `squash`. Non-squash `govern.merge.method` values are rejected by config validation.
|
|
66
66
|
|
data/MANUAL.md
CHANGED
|
@@ -66,12 +66,12 @@ on:
|
|
|
66
66
|
|
|
67
67
|
jobs:
|
|
68
68
|
governance:
|
|
69
|
-
uses: wanghailei/carson/.github/workflows/carson_policy.yml@v3.
|
|
69
|
+
uses: wanghailei/carson/.github/workflows/carson_policy.yml@v3.29.0
|
|
70
70
|
secrets:
|
|
71
71
|
CARSON_READ_TOKEN: ${{ secrets.CARSON_READ_TOKEN }}
|
|
72
72
|
with:
|
|
73
|
-
carson_ref: "v3.
|
|
74
|
-
carson_version: "3.
|
|
73
|
+
carson_ref: "v3.29.0"
|
|
74
|
+
carson_version: "3.29.0"
|
|
75
75
|
rubocop_version: "1.81.0"
|
|
76
76
|
```
|
|
77
77
|
|
|
@@ -209,11 +209,13 @@ carson abandon feature/stale-work
|
|
|
209
209
|
|
|
210
210
|
`abandon` closes the PR when it is still open, removes the matching worktree when safe, deletes the local and remote branch refs when allowed, and marks the delivery as failed in Carson's ledger.
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
`abandon` is an intentional discard: committed-but-unpushed branch work does not block abandonment. Typing `carson abandon` is explicit consent to discard committed work on that branch.
|
|
213
|
+
|
|
214
|
+
**Safety guards** — `abandon` blocks when:
|
|
213
215
|
- Shell CWD is inside the worktree (prevents session crash).
|
|
214
|
-
-
|
|
216
|
+
- Worktree has uncommitted changes (prevents accidental loss of unsaved work).
|
|
215
217
|
|
|
216
|
-
|
|
218
|
+
Committed-but-unpushed work is treated as intentional discard — `abandon` proceeds. The `worktree remove` command retains its own unpushed-commit guard and `--force` override for manual cleanup outside the abandon flow.
|
|
217
219
|
|
|
218
220
|
**Stale worktree recovery** — if a worktree directory is destroyed externally (for example by a raw GitHub merge/delete flow), `worktree remove`, `worktree list`, `housekeep`, and `prune` handle the stale entry gracefully: they clean up the git registration and delete the branch without error when Carson has enough evidence. Use Carson's delivery and cleanup commands instead of raw `gh pr merge --delete-branch` so the worktree directory stays intact for orderly cleanup.
|
|
219
221
|
|
|
@@ -299,7 +301,7 @@ carson housekeep --all --loop 300 # housekeep every 5 minutes
|
|
|
299
301
|
|
|
300
302
|
`refresh --all` checks each repo for safety before operating: repos with active worktrees or uncommitted changes are skipped with clear reasons. Other batch commands attempt each repo and report failures without stopping.
|
|
301
303
|
|
|
302
|
-
`housekeep --all --loop SECONDS` runs the full housekeep cycle continuously, sleeping SECONDS between passes. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` with a cycle count summary.
|
|
304
|
+
`housekeep --all --loop SECONDS` runs the full housekeep cycle continuously, sleeping SECONDS between passes. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` or `SIGTERM` with a cycle count summary.
|
|
303
305
|
|
|
304
306
|
**Periodic maintenance:**
|
|
305
307
|
|
|
@@ -319,7 +321,7 @@ carson govern --loop 300 --dry-run # observe mode, no integration or revision
|
|
|
319
321
|
|
|
320
322
|
The loop is built-in and cross-platform — no cron, launchd, or Task Scheduler required. Run it in a terminal, tmux, screen, or as a system service.
|
|
321
323
|
|
|
322
|
-
Each cycle runs independently: if one cycle fails (network error, GitHub API timeout), the error is logged and the next cycle proceeds normally. Press `Ctrl-C` to stop — Carson exits cleanly with a cycle count summary.
|
|
324
|
+
Each cycle runs independently: if one cycle fails (network error, GitHub API timeout), the error is logged and the next cycle proceeds normally. Press `Ctrl-C` or send `SIGTERM` to stop — Carson exits cleanly with a cycle count summary.
|
|
323
325
|
|
|
324
326
|
### Govern and Coding Agents
|
|
325
327
|
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,19 @@ 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.29.0
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **Govern and housekeep loops are clearer and safer to run unattended** — `carson govern --loop` now prints per-delivery progress hints and a sleep announcement with the next-cycle timestamp, and both `govern --loop` and `housekeep --all --loop` now stop cleanly on `SIGTERM` as well as `Ctrl-C`.
|
|
13
|
+
- **Govern and worktree creation no longer mutate the user's main worktree behind their back** — govern now fetches instead of syncing/pruning after merges, worktree creation branches from the remote tracking ref instead of pulling on main, and revision dispatch now defers when the target worktree is busy or dirty.
|
|
14
|
+
- **Abandon guidance now matches the actual safety contract** — `carson abandon` no longer suggests an unsupported `--force`, and committed-but-unpushed branch work no longer blocks abandonment; only dirty worktree changes do.
|
|
15
|
+
- **CI and release workflows are hardened for the Node 24 transition** — GitHub Actions checkouts now use `actions/checkout@v6`, Carson includes a dedicated forced-Node-24 probe for the RubyGems credentials action before changing the release path, and the standalone review smoke script now seeds its temporary `main` branch without tripping the shared main-branch commit guard.
|
|
16
|
+
|
|
17
|
+
### No migration required
|
|
18
|
+
|
|
19
|
+
- Existing workflows continue to work unchanged.
|
|
20
|
+
|
|
8
21
|
## 3.28.0
|
|
9
22
|
|
|
10
23
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.29.0
|
data/lib/carson/ledger.rb
CHANGED
|
@@ -274,6 +274,11 @@ module Carson
|
|
|
274
274
|
end
|
|
275
275
|
|
|
276
276
|
def migrate_legacy_state_if_needed!
|
|
277
|
+
# Skip lock acquisition entirely when no legacy SQLite file exists.
|
|
278
|
+
# Read-only file checks are safe without the lock; the migration
|
|
279
|
+
# itself is idempotent so a narrow race is harmless.
|
|
280
|
+
return unless state_path_requires_migration?
|
|
281
|
+
|
|
277
282
|
with_state_lock do |lock_file|
|
|
278
283
|
lock_file.flock( File::LOCK_EX )
|
|
279
284
|
source_path = legacy_sqlite_source_path
|
|
@@ -44,7 +44,7 @@ module Carson
|
|
|
44
44
|
|
|
45
45
|
if worktree
|
|
46
46
|
remove_exit = with_captured_output do
|
|
47
|
-
worktree_remove!( worktree_path: worktree.path, json_output: false )
|
|
47
|
+
worktree_remove!( worktree_path: worktree.path, skip_unpushed: true, json_output: false )
|
|
48
48
|
end
|
|
49
49
|
unless remove_exit == EXIT_OK
|
|
50
50
|
result[ :error ] = "worktree cleanup failed for #{worktree.path}"
|
|
@@ -131,29 +131,32 @@ module Carson
|
|
|
131
131
|
nil
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
+
# Abandon is an intentional discard: committed-but-unpushed work
|
|
135
|
+
# does not block abandonment. Only uncommitted (dirty) worktree
|
|
136
|
+
# changes block, because those may be accidental.
|
|
134
137
|
def abandon_preflight_issue( branch:, worktree: )
|
|
135
138
|
if config.protected_branches.include?( branch )
|
|
136
139
|
return { exit_code: EXIT_BLOCK, error: "cannot abandon protected branch #{branch}", recovery: "choose a feature branch instead" }
|
|
137
140
|
end
|
|
138
141
|
|
|
139
142
|
if worktree
|
|
140
|
-
check = Worktree.remove_check( path: worktree.path, runtime: self, force: false )
|
|
143
|
+
check = Worktree.remove_check( path: worktree.path, runtime: self, force: false, skip_unpushed: true )
|
|
141
144
|
return nil if check.fetch( :status ) == :ok
|
|
142
145
|
|
|
146
|
+
recovery = check.fetch( :recovery )
|
|
147
|
+
if check.fetch( :error ) == "worktree has uncommitted changes"
|
|
148
|
+
recovery = "commit or discard the changes, then retry carson abandon #{branch}"
|
|
149
|
+
end
|
|
150
|
+
|
|
143
151
|
return {
|
|
144
152
|
exit_code: check.fetch( :exit_code ),
|
|
145
153
|
error: check.fetch( :error ),
|
|
146
|
-
recovery:
|
|
154
|
+
recovery: recovery
|
|
147
155
|
}
|
|
148
156
|
end
|
|
149
157
|
|
|
150
158
|
return { exit_code: EXIT_BLOCK, error: "current branch is #{branch}", recovery: "switch to main or a different branch, then retry" } if current_branch == branch
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
unpushed = Worktree.branch_unpushed_issue( branch: branch, worktree_path: repo_root, runtime: self )
|
|
154
|
-
return nil if unpushed.nil?
|
|
155
|
-
|
|
156
|
-
{ exit_code: EXIT_BLOCK, error: unpushed.fetch( :error ), recovery: unpushed.fetch( :recovery ) }
|
|
159
|
+
nil
|
|
157
160
|
end
|
|
158
161
|
|
|
159
162
|
def close_pull_request!( number:, result: )
|
|
@@ -39,17 +39,17 @@ module Carson
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def govern_loop!( dry_run:, json_output:, loop_seconds: )
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
run_signal_aware_loop!(
|
|
43
|
+
loop_name: "govern",
|
|
44
|
+
loop_seconds: loop_seconds,
|
|
45
|
+
cycle_line: ->( cycle_count ) { "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" },
|
|
46
|
+
sleep_line: ->( seconds ) do
|
|
47
|
+
next_at = Time.now + seconds
|
|
48
|
+
"sleeping #{seconds}s — next cycle at #{next_at.strftime( '%Y-%m-%d %H:%M:%S %z' )}"
|
|
49
|
+
end
|
|
50
|
+
) do
|
|
47
51
|
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
48
|
-
sleep loop_seconds
|
|
49
52
|
end
|
|
50
|
-
rescue Interrupt
|
|
51
|
-
puts_line "govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
52
|
-
EXIT_OK
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
private
|
|
@@ -85,6 +85,8 @@ module Carson
|
|
|
85
85
|
next_to_integrate = reconciled.find( &:ready? )&.key
|
|
86
86
|
|
|
87
87
|
reconciled.each do |delivery|
|
|
88
|
+
hint = delivery_action_hint( delivery: delivery, next_to_integrate: next_to_integrate, dry_run: dry_run )
|
|
89
|
+
puts_line " #{delivery.branch} — #{hint}" if hint && !silent
|
|
88
90
|
delivery_report = scoped_runtime.send(
|
|
89
91
|
:decide_delivery_action,
|
|
90
92
|
delivery: delivery,
|
|
@@ -246,15 +248,10 @@ module Carson
|
|
|
246
248
|
pull_request_draft: false,
|
|
247
249
|
pull_request_merged_at: Time.now.utc.iso8601
|
|
248
250
|
)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
summary: "proof unavailable — local #{config.main_branch} sync did not complete."
|
|
254
|
-
)
|
|
255
|
-
else
|
|
256
|
-
merge_proof_for_branch( branch: integrated.branch, main_ref: config.main_branch )
|
|
257
|
-
end
|
|
251
|
+
# Fetch-only: update the remote tracking ref without mutating the
|
|
252
|
+
# main worktree. Reap and prune are deferred to explicit housekeep.
|
|
253
|
+
fetch_for_merge_proof!( repo_path: repo_path )
|
|
254
|
+
proof = merge_proof_for_remote_ref( branch: integrated.branch )
|
|
258
255
|
ledger.update_delivery(
|
|
259
256
|
delivery: integrated,
|
|
260
257
|
merge_proof: proof
|
|
@@ -274,6 +271,27 @@ module Carson
|
|
|
274
271
|
return escalate_delivery!( delivery: delivery, reason: "no agent provider available" ) if provider.nil?
|
|
275
272
|
return escalate_delivery!( delivery: delivery, reason: "worktree missing for revision" ) unless File.directory?( delivery.worktree_path.to_s )
|
|
276
273
|
|
|
274
|
+
# Defer if the target worktree is occupied — temporary hold, not failure.
|
|
275
|
+
worktree = Carson::Worktree.find( path: delivery.worktree_path.to_s, runtime: self )
|
|
276
|
+
if worktree
|
|
277
|
+
if worktree.held_by_other_process?
|
|
278
|
+
return ledger.update_delivery(
|
|
279
|
+
delivery: delivery,
|
|
280
|
+
status: "gated",
|
|
281
|
+
cause: "busy",
|
|
282
|
+
summary: "worktree held by another process — deferring revision"
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
if worktree.dirty?
|
|
286
|
+
return ledger.update_delivery(
|
|
287
|
+
delivery: delivery,
|
|
288
|
+
status: "gated",
|
|
289
|
+
cause: "busy",
|
|
290
|
+
summary: "worktree has uncommitted changes — deferring revision"
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
277
295
|
objective = revision_objective( cause: delivery.cause )
|
|
278
296
|
context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
|
|
279
297
|
work_order = Adapters::Agent::WorkOrder.new(
|
|
@@ -340,7 +358,16 @@ module Carson
|
|
|
340
358
|
end
|
|
341
359
|
|
|
342
360
|
def held_delivery?( delivery: )
|
|
343
|
-
[ "merge", "freshness" ].include?( delivery.cause )
|
|
361
|
+
[ "merge", "freshness", "busy" ].include?( delivery.cause )
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def delivery_action_hint( delivery:, next_to_integrate:, dry_run: )
|
|
365
|
+
return nil if dry_run
|
|
366
|
+
return nil if delivery.superseded? || delivery.integrated? || delivery.failed?
|
|
367
|
+
return "integrating…" if delivery.ready? && delivery.key == next_to_integrate
|
|
368
|
+
return nil unless delivery.blocked?
|
|
369
|
+
return nil if held_delivery?( delivery: delivery )
|
|
370
|
+
delivery.revision_count >= 3 ? "escalating…" : "revising…"
|
|
344
371
|
end
|
|
345
372
|
|
|
346
373
|
def housekeep_repo!( repo_path: )
|
|
@@ -348,6 +375,15 @@ module Carson
|
|
|
348
375
|
scoped_runtime.send( :housekeep_one_entry, repo_path: repo_path, silent: true )
|
|
349
376
|
end
|
|
350
377
|
|
|
378
|
+
# Fetch-only helper for post-merge proof generation.
|
|
379
|
+
# Updates the remote tracking ref without mutating the main worktree.
|
|
380
|
+
def fetch_for_merge_proof!( repo_path: )
|
|
381
|
+
scoped = repo_runtime_for( repo_path: repo_path )
|
|
382
|
+
scoped.send( :git_run, "fetch", scoped.config.git_remote, "--prune" )
|
|
383
|
+
rescue StandardError
|
|
384
|
+
# Best-effort — merge proof falls back to unavailable if fetch fails.
|
|
385
|
+
end
|
|
386
|
+
|
|
351
387
|
def select_agent_provider
|
|
352
388
|
provider = config.govern_agent_provider
|
|
353
389
|
case provider
|
|
@@ -81,17 +81,13 @@ module Carson
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def housekeep_loop!( json_output:, dry_run:, loop_seconds: )
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
run_signal_aware_loop!(
|
|
85
|
+
loop_name: "housekeep",
|
|
86
|
+
loop_seconds: loop_seconds,
|
|
87
|
+
cycle_line: ->( cycle_count ) { "housekeep cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" }
|
|
88
|
+
) do
|
|
89
89
|
housekeep_all!( json_output: json_output, dry_run: dry_run )
|
|
90
|
-
sleep loop_seconds
|
|
91
90
|
end
|
|
92
|
-
rescue Interrupt
|
|
93
|
-
puts_line "housekeep loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
94
|
-
EXIT_OK
|
|
95
91
|
end
|
|
96
92
|
|
|
97
93
|
# Prints a dry-run plan for this repo without making any changes.
|
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
module Carson
|
|
3
3
|
class Runtime
|
|
4
4
|
module Local
|
|
5
|
+
# Generates merge proof against the remote tracking ref directly.
|
|
6
|
+
# Skips the local-main trust check — the caller is responsible for
|
|
7
|
+
# fetching before calling. Used by govern's post-merge path to avoid
|
|
8
|
+
# mutating the main worktree.
|
|
9
|
+
def merge_proof_for_remote_ref( branch:, remote: config.git_remote, main_ref: config.main_branch )
|
|
10
|
+
remote_ref = "#{remote}/#{main_ref}"
|
|
11
|
+
return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
|
|
12
|
+
|
|
13
|
+
candidate = merge_proof_candidate( branch: branch, main_ref: remote_ref )
|
|
14
|
+
return candidate if candidate.fetch( :basis ) == "unavailable"
|
|
15
|
+
|
|
16
|
+
# Normalise display: show the local branch name, not the remote tracking ref.
|
|
17
|
+
candidate.merge(
|
|
18
|
+
main_branch: main_ref,
|
|
19
|
+
summary: candidate.fetch( :summary ).gsub( remote_ref, main_ref )
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
5
23
|
def merge_proof_for_branch( branch:, main_ref: config.main_branch )
|
|
6
24
|
return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
|
|
7
25
|
|
|
@@ -14,8 +14,8 @@ module Carson
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Removes a worktree: directory, git registration, and branch.
|
|
17
|
-
def worktree_remove!( worktree_path:, force: false, json_output: false )
|
|
18
|
-
Worktree.remove!( path: worktree_path, runtime: self, force: force, json_output: json_output )
|
|
17
|
+
def worktree_remove!( worktree_path:, force: false, skip_unpushed: false, json_output: false )
|
|
18
|
+
Worktree.remove!( path: worktree_path, runtime: self, force: force, skip_unpushed: skip_unpushed, json_output: json_output )
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Removes agent-owned worktrees whose branch content is already on main.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Shared loop runner for commands that poll on a schedule.
|
|
2
|
+
module Carson
|
|
3
|
+
class Runtime
|
|
4
|
+
module LoopRunner
|
|
5
|
+
LOOP_STOP_SIGNALS = %w[INT TERM].freeze
|
|
6
|
+
LOOP_SLEEP_SLICE_SECONDS = 0.25
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def run_signal_aware_loop!( loop_name:, loop_seconds:, cycle_line:, sleep_line: nil )
|
|
11
|
+
cycle_count = 0
|
|
12
|
+
stop_requested = false
|
|
13
|
+
previous_handlers = install_loop_stop_handlers! do
|
|
14
|
+
stop_requested = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
break if stop_requested
|
|
19
|
+
|
|
20
|
+
cycle_count += 1
|
|
21
|
+
puts_line ""
|
|
22
|
+
puts_line cycle_line.call( cycle_count )
|
|
23
|
+
yield cycle_count
|
|
24
|
+
break if stop_requested
|
|
25
|
+
|
|
26
|
+
puts_line sleep_line.call( loop_seconds ) if sleep_line
|
|
27
|
+
loop_runner_wait( seconds: loop_seconds ) { stop_requested }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
31
|
+
EXIT_OK
|
|
32
|
+
rescue Interrupt
|
|
33
|
+
puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
34
|
+
EXIT_OK
|
|
35
|
+
rescue SignalException => exception
|
|
36
|
+
raise unless graceful_loop_signal?( exception )
|
|
37
|
+
|
|
38
|
+
puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
39
|
+
EXIT_OK
|
|
40
|
+
ensure
|
|
41
|
+
restore_loop_stop_handlers!( previous_handlers ) if previous_handlers
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def install_loop_stop_handlers!
|
|
45
|
+
LOOP_STOP_SIGNALS.each_with_object( {} ) do |signal_name, handlers|
|
|
46
|
+
handlers[ signal_name ] = loop_runner_trap( signal_name ) { yield signal_name }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def restore_loop_stop_handlers!( previous_handlers )
|
|
51
|
+
previous_handlers.each do |signal_name, previous_handler|
|
|
52
|
+
loop_runner_trap( signal_name, previous_handler )
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def graceful_loop_signal?( exception )
|
|
57
|
+
signo = exception.respond_to?( :signo ) ? exception.signo : nil
|
|
58
|
+
LOOP_STOP_SIGNALS.any? do |signal_name|
|
|
59
|
+
Signal.list.fetch( signal_name, nil ) == signo
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def loop_runner_wait( seconds: )
|
|
64
|
+
deadline = loop_runner_monotonic_now + seconds.to_f
|
|
65
|
+
while ( remaining = deadline - loop_runner_monotonic_now ) > 0
|
|
66
|
+
break if block_given? && yield
|
|
67
|
+
loop_runner_sleep( [ remaining, LOOP_SLEEP_SLICE_SECONDS ].min )
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def loop_runner_trap( signal_name, handler = nil, &block )
|
|
72
|
+
if handler
|
|
73
|
+
Signal.trap( signal_name, handler )
|
|
74
|
+
else
|
|
75
|
+
Signal.trap( signal_name, &block )
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def loop_runner_monotonic_now
|
|
80
|
+
Process.clock_gettime( Process::CLOCK_MONOTONIC )
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def loop_runner_sleep( seconds )
|
|
84
|
+
sleep seconds
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
include LoopRunner
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/carson/runtime.rb
CHANGED
|
@@ -33,11 +33,17 @@ module Carson
|
|
|
33
33
|
@config = Config.load( repo_root: repo_root )
|
|
34
34
|
@git_adapter = Adapters::Git.new( repo_root: repo_root )
|
|
35
35
|
@github_adapter = Adapters::GitHub.new( repo_root: repo_root )
|
|
36
|
-
@ledger = Ledger.new( path: @config.govern_state_path )
|
|
37
36
|
@template_sync_result = nil
|
|
38
37
|
end
|
|
39
38
|
|
|
40
|
-
attr_reader :template_sync_result
|
|
39
|
+
attr_reader :template_sync_result
|
|
40
|
+
|
|
41
|
+
# Lazy ledger: only constructed when a command actually needs delivery state.
|
|
42
|
+
# Read-only commands (worktree list, audit, prune, sync) never touch the
|
|
43
|
+
# govern state lock file.
|
|
44
|
+
def ledger
|
|
45
|
+
@ledger ||= Ledger.new( path: @config.govern_state_path )
|
|
46
|
+
end
|
|
41
47
|
|
|
42
48
|
private
|
|
43
49
|
|
|
@@ -364,6 +370,7 @@ end
|
|
|
364
370
|
|
|
365
371
|
require_relative "runtime/local"
|
|
366
372
|
require_relative "runtime/audit"
|
|
373
|
+
require_relative "runtime/loop_runner"
|
|
367
374
|
require_relative "runtime/housekeep"
|
|
368
375
|
require_relative "runtime/repos"
|
|
369
376
|
require_relative "runtime/review"
|
data/lib/carson/worktree.rb
CHANGED
|
@@ -96,12 +96,23 @@ module Carson
|
|
|
96
96
|
# Determine the base branch (main branch from config).
|
|
97
97
|
base = runtime.config.main_branch
|
|
98
98
|
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# Best-effort — if pull fails (non-ff, offline), continue anyway.
|
|
99
|
+
# Fetch to update the remote tracking ref without mutating the main worktree.
|
|
100
|
+
# Best-effort — if fetch fails (no remote, offline), branch from local main.
|
|
102
101
|
main_root = runtime.main_worktree_root
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
remote = runtime.config.git_remote
|
|
103
|
+
_, _, fetch_ok, = Open3.capture3( "git", "-C", main_root, "fetch", remote, base )
|
|
104
|
+
if fetch_ok.success?
|
|
105
|
+
remote_ref = "#{remote}/#{base}"
|
|
106
|
+
_, _, ref_ok, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", remote_ref )
|
|
107
|
+
if ref_ok.success?
|
|
108
|
+
base = remote_ref
|
|
109
|
+
runtime.puts_verbose( "branching from #{remote_ref}" ) unless json_output
|
|
110
|
+
else
|
|
111
|
+
runtime.puts_verbose( "fetch succeeded but #{remote_ref} not found — branching from local #{runtime.config.main_branch}" ) unless json_output
|
|
112
|
+
end
|
|
113
|
+
else
|
|
114
|
+
runtime.puts_verbose( "fetch skipped — branching from local #{runtime.config.main_branch}" ) unless json_output
|
|
115
|
+
end
|
|
105
116
|
|
|
106
117
|
# Ensure .claude/ is excluded from git status in the host repository.
|
|
107
118
|
# Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
|
|
@@ -144,7 +155,7 @@ module Carson
|
|
|
144
155
|
# Removes a worktree: directory, git registration, and branch.
|
|
145
156
|
# Never forces removal — if the worktree has uncommitted changes, refuses unless
|
|
146
157
|
# the caller explicitly passes force: true via CLI --force flag.
|
|
147
|
-
def self.remove!( path:, runtime:, force: false, json_output: false )
|
|
158
|
+
def self.remove!( path:, runtime:, force: false, skip_unpushed: false, json_output: false )
|
|
148
159
|
fingerprint_status = runtime.block_if_outsider_fingerprints!
|
|
149
160
|
unless fingerprint_status.nil?
|
|
150
161
|
if json_output
|
|
@@ -158,7 +169,7 @@ module Carson
|
|
|
158
169
|
return fingerprint_status
|
|
159
170
|
end
|
|
160
171
|
|
|
161
|
-
check = remove_check( path: path, runtime: runtime, force: force )
|
|
172
|
+
check = remove_check( path: path, runtime: runtime, force: force, skip_unpushed: skip_unpushed )
|
|
162
173
|
unless check.fetch( :status ) == :ok
|
|
163
174
|
return finish(
|
|
164
175
|
result: { command: "worktree remove", status: check.fetch( :result_status ), name: File.basename( check.fetch( :resolved_path ) ),
|
|
@@ -236,7 +247,7 @@ module Carson
|
|
|
236
247
|
# Preflight guard for worktree removal. Shared by `worktree remove` and
|
|
237
248
|
# other runtime flows that need to know whether cleanup is safe before
|
|
238
249
|
# mutating GitHub or branch state.
|
|
239
|
-
def self.remove_check( path:, runtime:, force: false )
|
|
250
|
+
def self.remove_check( path:, runtime:, force: false, skip_unpushed: false )
|
|
240
251
|
resolved_path = resolve_path( path: path, runtime: runtime )
|
|
241
252
|
|
|
242
253
|
if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
|
|
@@ -296,7 +307,7 @@ module Carson
|
|
|
296
307
|
}
|
|
297
308
|
end
|
|
298
309
|
|
|
299
|
-
unless force
|
|
310
|
+
unless force || skip_unpushed
|
|
300
311
|
unpushed = branch_unpushed_issue( branch: branch, worktree_path: resolved_path, runtime: runtime )
|
|
301
312
|
if unpushed
|
|
302
313
|
return {
|
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.
|
|
4
|
+
version: 3.29.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -89,6 +89,7 @@ files:
|
|
|
89
89
|
- lib/carson/runtime/local/sync.rb
|
|
90
90
|
- lib/carson/runtime/local/template.rb
|
|
91
91
|
- lib/carson/runtime/local/worktree.rb
|
|
92
|
+
- lib/carson/runtime/loop_runner.rb
|
|
92
93
|
- lib/carson/runtime/recover.rb
|
|
93
94
|
- lib/carson/runtime/repos.rb
|
|
94
95
|
- lib/carson/runtime/review.rb
|