carson 4.3.2 → 4.3.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 +4 -4
- data/RELEASE.md +10 -0
- data/VERSION +1 -1
- data/lib/carson/courier.rb +10 -10
- data/lib/carson/parcel.rb +11 -2
- data/lib/carson/runtime/receive.rb +1 -1
- data/lib/carson/warehouse/bureau.rb +8 -11
- data/lib/carson/warehouse/seal.rb +17 -16
- data/lib/carson/warehouse/vault.rb +36 -31
- data/lib/carson/warehouse/workbench.rb +21 -94
- data/lib/carson/warehouse.rb +68 -77
- data/lib/carson/worktree.rb +1 -1
- 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: 5c6a03fe7387ab7c3df9dc7d1bc7de0b716dbb38b68b18cf9f103f8df9a7114c
|
|
4
|
+
data.tar.gz: 57e656339b9a4307684fae58ed6d10f147e8c23d4cd2a45731dd85905c4f7b05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc0ed4785b032dba61497a99ff09e29d890777d69d0dd59b1d632955a2b92a294a66571c7d04caa45b61ed0b81d56c65ce5a4550c13dfe6de32670f0103c0c54
|
|
7
|
+
data.tar.gz: 2419020c706bf3b1d94233c077ffdeb05475d2f443d1b8d27a1386c368d1535b797707f171270d18d2707bc98a3daf10d4c4add82d298e694375cecd27ff848e
|
data/RELEASE.md
CHANGED
|
@@ -15,6 +15,16 @@ Release-note scope rule:
|
|
|
15
15
|
|
|
16
16
|
Audit was a remote-centred pre-commit gate — it blocked commits based on delivery-time concerns (PR checks, CI baseline) that don't belong at commit time. In local-centred mode it was purely dead weight. Rather than relocate it to bureau, we verified that none of its unique checks had proven value at any gate, and dissolved the whole thing. Scars, not speculation.
|
|
17
17
|
|
|
18
|
+
## 4.3.3
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`carson checkin` is 13× faster.** Removed two redundant `git fetch` calls that hit GitHub on every checkin. `checkin` now branches from local main — zero network, 0.7s instead of 9.4s. Local main is the standard; GitHub is a backup, not a gating dependency.
|
|
23
|
+
|
|
24
|
+
### Why
|
|
25
|
+
|
|
26
|
+
Local-centred mode means local main is the source of truth. Fetching from GitHub on every checkin was a remote-centred assumption baked into the command. The two sequential network round-trips accounted for 90% of the wall time.
|
|
27
|
+
|
|
18
28
|
## 4.3.2
|
|
19
29
|
|
|
20
30
|
### Fixed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.3.
|
|
1
|
+
4.3.3
|
data/lib/carson/courier.rb
CHANGED
|
@@ -137,15 +137,15 @@ module Carson
|
|
|
137
137
|
|
|
138
138
|
# 02. Parcel behind standard — not based on client's latest standard.
|
|
139
139
|
# The courier rebases automatically. Only blocks on conflict.
|
|
140
|
-
unless @warehouse.
|
|
140
|
+
unless @warehouse.receive_latest!
|
|
141
141
|
return blocked( result,
|
|
142
142
|
"cannot verify freshness — fetch failed",
|
|
143
143
|
recovery: "carson sync, then carson deliver" )
|
|
144
144
|
end
|
|
145
|
-
unless @warehouse.
|
|
145
|
+
unless @warehouse.based_on_latest?( parcel )
|
|
146
146
|
remote_main = "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
|
|
147
147
|
say "Branch is behind #{remote_main} — rebasing..."
|
|
148
|
-
unless @warehouse.
|
|
148
|
+
unless @warehouse.rebase!
|
|
149
149
|
return blocked( result,
|
|
150
150
|
"rebase conflict onto #{remote_main}",
|
|
151
151
|
recovery: "resolve conflicts, then carson deliver" )
|
|
@@ -175,8 +175,8 @@ module Carson
|
|
|
175
175
|
result[ :tracking_number ] = waybill.tracking_number
|
|
176
176
|
result[ :url ] = waybill.url
|
|
177
177
|
|
|
178
|
-
# Seal the
|
|
179
|
-
@warehouse.
|
|
178
|
+
# Seal the workbench — no more packing until the outcome is confirmed.
|
|
179
|
+
@warehouse.seal!( tracking: waybill.tracking_number )
|
|
180
180
|
|
|
181
181
|
# Wait at the bureau while the bureaucrats check the parcel.
|
|
182
182
|
wait_and_poll_at_bureau( waybill, result )
|
|
@@ -185,7 +185,7 @@ module Carson
|
|
|
185
185
|
# delivered/held/rejected → unseal (shelf done or parcel returned)
|
|
186
186
|
# filed → stay sealed (parcel still in flight)
|
|
187
187
|
outcome = result[ :outcome ]
|
|
188
|
-
@warehouse.
|
|
188
|
+
@warehouse.unseal! if outcome == "delivered" || outcome == "held" || outcome == "rejected"
|
|
189
189
|
|
|
190
190
|
# Update the ledger with the final outcome and PR identity.
|
|
191
191
|
record( parcel, status: outcome || "filed", summary: result[ :hold_reason ], waybill: waybill )
|
|
@@ -201,12 +201,12 @@ module Carson
|
|
|
201
201
|
# checks are exhausted.
|
|
202
202
|
def wait_and_poll_at_bureau( waybill, result )
|
|
203
203
|
MAX_CHECKS_AT_BUREAU.times do |check|
|
|
204
|
-
@warehouse.
|
|
204
|
+
@warehouse.check_parcel_with( waybill )
|
|
205
205
|
|
|
206
206
|
# 14/17. Already accepted — parcel is in the registry.
|
|
207
207
|
if waybill.accepted?
|
|
208
208
|
result[ :outcome ] = "delivered"
|
|
209
|
-
result[ :synced ] = @warehouse.
|
|
209
|
+
result[ :synced ] = @warehouse.receive_latest!
|
|
210
210
|
return
|
|
211
211
|
end
|
|
212
212
|
|
|
@@ -219,11 +219,11 @@ module Carson
|
|
|
219
219
|
|
|
220
220
|
# Cleared or mergeability pending — ask the warehouse to register.
|
|
221
221
|
if waybill.cleared? || waybill.mergeability_pending?
|
|
222
|
-
@warehouse.
|
|
222
|
+
@warehouse.register_with!( waybill, method: @merge_method )
|
|
223
223
|
|
|
224
224
|
if waybill.accepted?
|
|
225
225
|
result[ :outcome ] = "delivered"
|
|
226
|
-
result[ :synced ] = @warehouse.
|
|
226
|
+
result[ :synced ] = @warehouse.receive_latest!
|
|
227
227
|
return
|
|
228
228
|
end
|
|
229
229
|
end
|
data/lib/carson/parcel.rb
CHANGED
|
@@ -11,12 +11,13 @@ module Carson
|
|
|
11
11
|
# head (commit SHA), and shelf (worktree). The protagonist of every
|
|
12
12
|
# delivery — without a parcel, there is nothing to deliver.
|
|
13
13
|
class Parcel
|
|
14
|
-
attr_reader :label, :head, :shelf
|
|
14
|
+
attr_reader :label, :head, :shelf, :origin
|
|
15
15
|
|
|
16
|
-
def initialize( label:, head:, shelf: nil )
|
|
16
|
+
def initialize( label:, head:, shelf: nil, origin: nil )
|
|
17
17
|
@label = label
|
|
18
18
|
@head = head
|
|
19
19
|
@shelf = shelf
|
|
20
|
+
@origin = origin
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
# Is this parcel sitting on the main shelf?
|
|
@@ -24,5 +25,13 @@ module Carson
|
|
|
24
25
|
def on_main?( main_label )
|
|
25
26
|
label == main_label
|
|
26
27
|
end
|
|
28
|
+
|
|
29
|
+
# Does this parcel carry anything?
|
|
30
|
+
# A parcel with no commits ahead of its origin is empty —
|
|
31
|
+
# nothing was packed, nothing to deliver.
|
|
32
|
+
def empty?
|
|
33
|
+
return false unless origin
|
|
34
|
+
head == origin
|
|
35
|
+
end
|
|
27
36
|
end
|
|
28
37
|
end
|
|
@@ -87,7 +87,7 @@ module Carson
|
|
|
87
87
|
# Unseal worktrees that were filed — receive now owns the delivery lifecycle.
|
|
88
88
|
unless dry_run
|
|
89
89
|
filed_worktree_paths.each do |worktree_path|
|
|
90
|
-
Warehouse.new( path: worktree_path ).
|
|
90
|
+
Warehouse.new( path: worktree_path ).unseal!
|
|
91
91
|
end
|
|
92
92
|
end
|
|
93
93
|
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
# The warehouse's bureau
|
|
2
|
-
# The
|
|
3
|
-
#
|
|
1
|
+
# The warehouse's bureau concern — backup and optional PR/CI.
|
|
2
|
+
# The bureau is just a backup. These methods exist for when
|
|
3
|
+
# the courier needs the warehouse to interact with it.
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
module Carson
|
|
7
7
|
class Warehouse
|
|
8
8
|
module Bureau
|
|
9
9
|
|
|
10
|
-
# Check
|
|
11
|
-
|
|
12
|
-
def check_parcel_at_bureau_with( waybill )
|
|
10
|
+
# Check a parcel's status using the waybill.
|
|
11
|
+
def check_parcel_with( waybill )
|
|
13
12
|
state = fetch_pr_state_for( waybill.tracking_number )
|
|
14
13
|
ci, ci_diagnostic = fetch_ci_state_for( waybill.tracking_number )
|
|
15
14
|
waybill.record( state: state, ci: ci, ci_diagnostic: ci_diagnostic )
|
|
@@ -47,15 +46,13 @@ module Carson
|
|
|
47
46
|
Waybill.new( label: parcel.label, tracking_number: tracking_number, url: url )
|
|
48
47
|
end
|
|
49
48
|
|
|
50
|
-
# Register
|
|
51
|
-
|
|
52
|
-
def register_parcel_at_bureau_with!( waybill, method: )
|
|
49
|
+
# Register a parcel using the waybill.
|
|
50
|
+
def register_with!( waybill, method: )
|
|
53
51
|
_, _, status = gh( "pr", "merge", waybill.tracking_number.to_s, "--#{method}" )
|
|
54
52
|
if status.success?
|
|
55
53
|
waybill.stamp( :accepted )
|
|
56
54
|
else
|
|
57
|
-
|
|
58
|
-
check_parcel_at_bureau_with( waybill )
|
|
55
|
+
check_parcel_with( waybill )
|
|
59
56
|
end
|
|
60
57
|
end
|
|
61
58
|
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
# The warehouse's
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
1
|
+
# The warehouse's seal concern — bureau enhancement only.
|
|
2
|
+
#
|
|
3
|
+
# The seal is inactive in the base (local-centred) model.
|
|
4
|
+
# There is no "in flight" period — the parcel goes directly
|
|
5
|
+
# into the vault.
|
|
6
|
+
#
|
|
7
|
+
# The seal only activates with bureau enhancement, where there's
|
|
8
|
+
# a waiting period between shipping and bureau acceptance. During
|
|
9
|
+
# that period, the workbench is sealed — no more packing until
|
|
10
|
+
# the delivery outcome is confirmed.
|
|
11
|
+
#
|
|
12
|
+
# The seal marker lives outside the worktree (~/.carson/seals/)
|
|
13
|
+
# so it does not pollute git status.
|
|
6
14
|
require "digest"
|
|
7
15
|
require "fileutils"
|
|
8
16
|
|
|
@@ -10,17 +18,15 @@ module Carson
|
|
|
10
18
|
class Warehouse
|
|
11
19
|
module Seal
|
|
12
20
|
|
|
13
|
-
# Seal the workbench — no more packing until
|
|
14
|
-
|
|
15
|
-
def seal_workbench!( tracking_number: )
|
|
21
|
+
# Seal the workbench — no more packing until the bureau answers.
|
|
22
|
+
def seal!( tracking: )
|
|
16
23
|
marker = delivering_marker_path
|
|
17
24
|
FileUtils.mkdir_p( File.dirname( marker ) )
|
|
18
|
-
File.write( marker, "#{
|
|
25
|
+
File.write( marker, "#{tracking}\n#{@path}" )
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
# Unseal the workbench — the courier brought back the parcel.
|
|
22
|
-
|
|
23
|
-
def unseal_workbench!
|
|
29
|
+
def unseal!
|
|
24
30
|
File.delete( delivering_marker_path ) if File.exist?( delivering_marker_path )
|
|
25
31
|
end
|
|
26
32
|
|
|
@@ -35,11 +41,6 @@ module Carson
|
|
|
35
41
|
File.read( delivering_marker_path ).lines.first.strip
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
# --- Transitional aliases ---
|
|
39
|
-
# Keep old names working until all callers are updated.
|
|
40
|
-
alias seal_shelf! seal_workbench!
|
|
41
|
-
alias unseal_shelf! unseal_workbench!
|
|
42
|
-
|
|
43
44
|
private
|
|
44
45
|
|
|
45
46
|
# Path to the delivery marker file.
|
|
@@ -1,60 +1,66 @@
|
|
|
1
|
-
# The
|
|
2
|
-
# The vault is local main
|
|
3
|
-
#
|
|
4
|
-
# In remote-centred workstyle, the vault is the backup (receives
|
|
5
|
-
# the standard from the bureau's registry after acceptance).
|
|
1
|
+
# The vault — where the production standard lives.
|
|
2
|
+
# The vault is local main. It is the source of truth.
|
|
3
|
+
# Accepted parcels live here permanently.
|
|
6
4
|
require "open3"
|
|
7
5
|
|
|
8
6
|
module Carson
|
|
9
7
|
class Warehouse
|
|
10
|
-
|
|
8
|
+
class Vault
|
|
9
|
+
attr_reader :main_label
|
|
10
|
+
|
|
11
|
+
def initialize( path:, main_label: )
|
|
12
|
+
@path = path
|
|
13
|
+
@main_label = main_label
|
|
14
|
+
end
|
|
11
15
|
|
|
12
16
|
# Accept a parcel into the vault.
|
|
13
|
-
# Fast-forwards
|
|
14
|
-
# Runs from the main worktree root where main is checked out.
|
|
17
|
+
# Fast-forwards the standard to include the parcel's branch.
|
|
15
18
|
#
|
|
16
19
|
# Precondition: the parcel's branch must be a fast-forward of main.
|
|
17
20
|
# If not, the agent must rebase first.
|
|
18
|
-
#
|
|
19
|
-
# Returns a result hash:
|
|
20
|
-
# { status: "ok", branch: ..., head: ... }
|
|
21
|
-
# { status: "block", error: ..., recovery: ... }
|
|
22
|
-
# { status: "error", error: ..., recovery: ... }
|
|
23
21
|
def accept!( parcel )
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# Verify main is checked out in the main worktree.
|
|
27
|
-
unless main_checked_out_at?( root )
|
|
22
|
+
unless main_checked_out?
|
|
28
23
|
return {
|
|
29
24
|
status: "error",
|
|
30
25
|
error: "#{@main_label} is not checked out in the main worktree.",
|
|
31
|
-
recovery: "Check the main worktree state at #{
|
|
26
|
+
recovery: "Check the main worktree state at #{@path}."
|
|
32
27
|
}
|
|
33
28
|
end
|
|
34
29
|
|
|
35
|
-
# Fast-forward main to include the parcel's branch.
|
|
36
30
|
_, stderr, status = Open3.capture3(
|
|
37
|
-
"git", "-C",
|
|
31
|
+
"git", "-C", @path, "merge", "--ff-only", parcel.label
|
|
38
32
|
)
|
|
39
33
|
|
|
40
|
-
return
|
|
34
|
+
return accepted( parcel ) if status.success?
|
|
41
35
|
|
|
42
|
-
|
|
36
|
+
blocked( parcel, stderr )
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Has this label's content been absorbed into the vault?
|
|
40
|
+
# Content-aware — compares tree content, not SHA ancestry.
|
|
41
|
+
# Catches rebase-merged and squash-merged branches that
|
|
42
|
+
# ancestry-based checks miss (replayed SHAs differ).
|
|
43
|
+
def absorbed?( label )
|
|
44
|
+
_, _, status = Open3.capture3(
|
|
45
|
+
"git", "diff", "--quiet", @main_label, label,
|
|
46
|
+
chdir: @path
|
|
47
|
+
)
|
|
48
|
+
status.success?
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
private
|
|
46
52
|
|
|
47
|
-
#
|
|
48
|
-
def
|
|
53
|
+
# Is main checked out in the vault's worktree?
|
|
54
|
+
def main_checked_out?
|
|
49
55
|
head_ref, _, status = Open3.capture3(
|
|
50
|
-
"git", "-C",
|
|
56
|
+
"git", "-C", @path, "rev-parse", "--abbrev-ref", "HEAD"
|
|
51
57
|
)
|
|
52
58
|
status.success? && head_ref.strip == @main_label
|
|
53
59
|
end
|
|
54
60
|
|
|
55
|
-
# Build the success result after
|
|
56
|
-
def
|
|
57
|
-
new_head, = Open3.capture3( "git", "-C",
|
|
61
|
+
# Build the success result after acceptance.
|
|
62
|
+
def accepted( parcel )
|
|
63
|
+
new_head, = Open3.capture3( "git", "-C", @path, "rev-parse", "HEAD" )
|
|
58
64
|
{
|
|
59
65
|
status: "ok",
|
|
60
66
|
branch: parcel.label,
|
|
@@ -62,10 +68,10 @@ module Carson
|
|
|
62
68
|
}
|
|
63
69
|
end
|
|
64
70
|
|
|
65
|
-
# Build the blocked
|
|
71
|
+
# Build the blocked result when acceptance fails.
|
|
66
72
|
# Distinguishes dirty-tree conflicts from diverged-history blocks
|
|
67
73
|
# so the agent gets the correct recovery advice.
|
|
68
|
-
def
|
|
74
|
+
def blocked( parcel, stderr )
|
|
69
75
|
if stderr.to_s.include?( "would be overwritten" )
|
|
70
76
|
{
|
|
71
77
|
status: "block",
|
|
@@ -80,7 +86,6 @@ module Carson
|
|
|
80
86
|
}
|
|
81
87
|
end
|
|
82
88
|
end
|
|
83
|
-
|
|
84
89
|
end
|
|
85
90
|
end
|
|
86
91
|
end
|
|
@@ -104,8 +104,8 @@ module Carson
|
|
|
104
104
|
|
|
105
105
|
# --- Lifecycle ---
|
|
106
106
|
|
|
107
|
-
# Build a new workbench from
|
|
108
|
-
# Creates the directory, branches from the
|
|
107
|
+
# Build a new workbench from local main.
|
|
108
|
+
# Creates the directory, branches from the local standard,
|
|
109
109
|
# ensures .claude/ is excluded from git status.
|
|
110
110
|
def build_workbench!( name: )
|
|
111
111
|
root = main_worktree_root
|
|
@@ -119,23 +119,12 @@ module Carson
|
|
|
119
119
|
recovery: "carson worktree remove #{name}, then retry" }
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
-
# Determine the base branch.
|
|
123
|
-
base = @main_label
|
|
124
|
-
|
|
125
|
-
# Fetch to update remote tracking ref without mutating the main worktree.
|
|
126
|
-
_, _, fetch_ok = git( "fetch", @bureau_address, base )
|
|
127
|
-
if fetch_ok.success?
|
|
128
|
-
remote_ref = "#{@bureau_address}/#{base}"
|
|
129
|
-
_, _, ref_ok = git( "rev-parse", "--verify", remote_ref )
|
|
130
|
-
base = remote_ref if ref_ok.success?
|
|
131
|
-
end
|
|
132
|
-
|
|
133
122
|
# Ensure .claude/ is excluded from git status.
|
|
134
123
|
ensure_claude_dir_excluded!
|
|
135
124
|
|
|
136
125
|
# Create the worktree with a new branch.
|
|
137
126
|
FileUtils.mkdir_p( File.dirname( workbench_path ) )
|
|
138
|
-
wt_stdout, wt_stderr, wt_status = git( "worktree", "add", workbench_path, "-b", name,
|
|
127
|
+
wt_stdout, wt_stderr, wt_status = git( "worktree", "add", workbench_path, "-b", name, @main_label )
|
|
139
128
|
unless wt_status.success?
|
|
140
129
|
error_text = wt_stderr.to_s.strip
|
|
141
130
|
error_text = "unable to create worktree" if error_text.empty?
|
|
@@ -159,11 +148,10 @@ module Carson
|
|
|
159
148
|
path: workbench_path, branch: name }
|
|
160
149
|
end
|
|
161
150
|
|
|
162
|
-
# Agent checks in — prepare a fresh workbench from
|
|
151
|
+
# Agent checks in — prepare a fresh workbench from local main.
|
|
163
152
|
# Sweeps delivered workbenches first — the Warehouse cleans behind the agent.
|
|
164
153
|
def checkin!( name: )
|
|
165
|
-
|
|
166
|
-
sweep_delivered_workbenches!
|
|
154
|
+
sweep!
|
|
167
155
|
result = build_workbench!( name: name )
|
|
168
156
|
result[ :command ] = "checkin"
|
|
169
157
|
result
|
|
@@ -237,19 +225,20 @@ module Carson
|
|
|
237
225
|
end
|
|
238
226
|
|
|
239
227
|
# Full safety assessment before removal.
|
|
228
|
+
# Asks the workbench about its own state — no duplicate checks.
|
|
240
229
|
# Returns { status: :ok } or { status: :block/:error, error:, recovery: }.
|
|
241
230
|
def assess_removal( workbench, force: false, skip_unpushed: false )
|
|
242
231
|
unless workbench.exists?
|
|
243
232
|
return { status: :ok, missing: true }
|
|
244
233
|
end
|
|
245
234
|
|
|
246
|
-
if
|
|
235
|
+
if workbench.holds_cwd?
|
|
247
236
|
return { status: :block, result_status: "block",
|
|
248
237
|
error: "current working directory is inside this worktree",
|
|
249
|
-
recovery: "cd #{main_worktree_root} && carson
|
|
238
|
+
recovery: "cd #{main_worktree_root} && carson checkout #{File.basename( workbench.path )}" }
|
|
250
239
|
end
|
|
251
240
|
|
|
252
|
-
if
|
|
241
|
+
if workbench.held_by_other_process?
|
|
253
242
|
return { status: :block, result_status: "block",
|
|
254
243
|
error: "another process has its working directory inside this worktree",
|
|
255
244
|
recovery: "wait for the other session to finish, then retry" }
|
|
@@ -269,9 +258,12 @@ module Carson
|
|
|
269
258
|
{ status: :ok, missing: false }
|
|
270
259
|
end
|
|
271
260
|
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
|
|
261
|
+
# The warehouse sweeps — autonomous housekeeping.
|
|
262
|
+
# Walks all agent-owned workbenches. Asks each one about its state.
|
|
263
|
+
# If the label has been absorbed into the vault, and the workbench
|
|
264
|
+
# isn't sealed or occupied — safe to remove, tears it down.
|
|
265
|
+
# Repairs missing ones.
|
|
266
|
+
def sweep!
|
|
275
267
|
root = main_worktree_root
|
|
276
268
|
|
|
277
269
|
agent_prefixes = AGENT_DIRS.map do |dir|
|
|
@@ -284,48 +276,17 @@ module Carson
|
|
|
284
276
|
next unless workbench.branch
|
|
285
277
|
next unless agent_prefixes.any? { |prefix| workbench.path.start_with?( prefix ) }
|
|
286
278
|
|
|
287
|
-
# Use the existing classifier if available (transitional).
|
|
288
|
-
if respond_to?( :classify_worktree_cleanup, true )
|
|
289
|
-
classification = classify_worktree_cleanup( worktree: workbench )
|
|
290
|
-
next unless classification.fetch( :action ) == :reap
|
|
291
|
-
end
|
|
292
|
-
|
|
293
279
|
unless workbench.exists?
|
|
294
280
|
repair_missing_workbench!( workbench )
|
|
295
281
|
next
|
|
296
282
|
end
|
|
297
283
|
|
|
298
|
-
|
|
299
|
-
next unless
|
|
300
|
-
|
|
301
|
-
if workbench.branch
|
|
302
|
-
git( "branch", "-D", workbench.branch )
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
private
|
|
308
|
-
|
|
309
|
-
# --- Sweep ---
|
|
310
|
-
|
|
311
|
-
# Sweep delivered workbenches — branches absorbed into main, not sealed,
|
|
312
|
-
# not CWD-blocked. Called by checkin! so the Warehouse cleans behind the agent.
|
|
313
|
-
def sweep_delivered_workbenches!
|
|
314
|
-
root = main_worktree_root
|
|
315
|
-
|
|
316
|
-
agent_prefixes = AGENT_DIRS.map do |dir|
|
|
317
|
-
full = File.join( root, dir, "worktrees" )
|
|
318
|
-
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
319
|
-
end.compact
|
|
320
|
-
return if agent_prefixes.empty?
|
|
284
|
+
# Only sweep workbenches whose content has been absorbed into the vault.
|
|
285
|
+
next unless absorbed?( workbench.branch )
|
|
321
286
|
|
|
322
|
-
|
|
323
|
-
next
|
|
324
|
-
next
|
|
325
|
-
next unless workbench.exists?
|
|
326
|
-
next unless label_absorbed?( workbench.branch )
|
|
327
|
-
next if agent_at_workbench?( workbench )
|
|
328
|
-
next if workbench_held_by_process?( workbench )
|
|
287
|
+
# Ask the workbench about its own state — not occupied, not held.
|
|
288
|
+
next if workbench.holds_cwd?
|
|
289
|
+
next if workbench.held_by_other_process?
|
|
329
290
|
|
|
330
291
|
# Do not sweep sealed workbenches — parcel still in flight.
|
|
331
292
|
seal_check = Warehouse.new( path: workbench.path )
|
|
@@ -338,41 +299,7 @@ module Carson
|
|
|
338
299
|
end
|
|
339
300
|
end
|
|
340
301
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
# Is the agent's working directory inside this workbench?
|
|
344
|
-
def agent_at_workbench?( workbench )
|
|
345
|
-
cwd = realpath_safe( Dir.pwd )
|
|
346
|
-
workbench_path = realpath_safe( workbench.path )
|
|
347
|
-
normalised = File.join( workbench_path, "" )
|
|
348
|
-
cwd == workbench_path || cwd.start_with?( normalised )
|
|
349
|
-
rescue StandardError
|
|
350
|
-
false
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# Is another process occupying this workbench?
|
|
354
|
-
def workbench_held_by_process?( workbench )
|
|
355
|
-
canonical = realpath_safe( workbench.path )
|
|
356
|
-
return false if canonical.nil? || canonical.empty?
|
|
357
|
-
return false unless Dir.exist?( canonical )
|
|
358
|
-
|
|
359
|
-
stdout, = Open3.capture3( "lsof", "-d", "cwd" )
|
|
360
|
-
return false if stdout.nil? || stdout.empty?
|
|
361
|
-
|
|
362
|
-
normalised = File.join( canonical, "" )
|
|
363
|
-
my_pid = Process.pid
|
|
364
|
-
stdout.lines.drop( 1 ).any? do |line|
|
|
365
|
-
fields = line.strip.split( /\s+/ )
|
|
366
|
-
next false unless fields.length >= 9
|
|
367
|
-
next false if fields[ 1 ].to_i == my_pid
|
|
368
|
-
name = fields[ 8.. ].join( " " )
|
|
369
|
-
name == canonical || name.start_with?( normalised )
|
|
370
|
-
end
|
|
371
|
-
rescue Errno::ENOENT
|
|
372
|
-
false
|
|
373
|
-
rescue StandardError
|
|
374
|
-
false
|
|
375
|
-
end
|
|
302
|
+
private
|
|
376
303
|
|
|
377
304
|
# Would tearing down lose unpushed work?
|
|
378
305
|
# Content-aware: compares tree content vs main, not SHAs.
|
data/lib/carson/warehouse.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
# A governed repository
|
|
2
|
-
# parcels are built on workbenches
|
|
3
|
-
#
|
|
4
|
-
#
|
|
1
|
+
# A governed repository — the intelligent, self-managing building
|
|
2
|
+
# where parcels are built on workbenches. Each warehouse belongs
|
|
3
|
+
# to a client. The warehouse is the local authority — everything
|
|
4
|
+
# inside the repository is its domain.
|
|
5
|
+
#
|
|
6
|
+
# At the heart of the warehouse is the vault — where the production
|
|
7
|
+
# standard lives. The vault is the source of truth.
|
|
5
8
|
require "fileutils"
|
|
6
9
|
require "open3"
|
|
7
10
|
|
|
@@ -11,13 +14,8 @@ require_relative "warehouse/seal"
|
|
|
11
14
|
require_relative "warehouse/bureau"
|
|
12
15
|
|
|
13
16
|
module Carson
|
|
14
|
-
# A governed repository — the warehouse where parcels are built on
|
|
15
|
-
# workbenches (worktrees) with labels (branches). An intelligent
|
|
16
|
-
# warehouse that manages itself: packing parcels, checking compliance,
|
|
17
|
-
# managing workbenches, and sweeping up.
|
|
18
17
|
class Warehouse
|
|
19
18
|
include Workbench
|
|
20
|
-
include Vault
|
|
21
19
|
include Seal
|
|
22
20
|
include Bureau
|
|
23
21
|
|
|
@@ -30,29 +28,46 @@ module Carson
|
|
|
30
28
|
@compliance_checker = compliance_checker
|
|
31
29
|
end
|
|
32
30
|
|
|
31
|
+
# --- The vault ---
|
|
32
|
+
|
|
33
|
+
# The vault — where the production standard lives.
|
|
34
|
+
def vault
|
|
35
|
+
@vault ||= Vault.new( path: main_worktree_root, main_label: @main_label )
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Accept a parcel into the vault.
|
|
39
|
+
def accept!( parcel )
|
|
40
|
+
vault.accept!( parcel )
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Has this label been absorbed into the vault?
|
|
44
|
+
def absorbed?( label )
|
|
45
|
+
vault.absorbed?( label )
|
|
46
|
+
end
|
|
47
|
+
|
|
33
48
|
# --- What the warehouse knows ---
|
|
34
49
|
|
|
35
|
-
# The label on the current workbench
|
|
50
|
+
# The label on the current workbench.
|
|
36
51
|
def current_label
|
|
37
52
|
git( "rev-parse", "--abbrev-ref", "HEAD" ).first.strip
|
|
38
53
|
end
|
|
39
54
|
|
|
40
|
-
# The tip of the parcel on the current workbench
|
|
55
|
+
# The tip of the parcel on the current workbench.
|
|
41
56
|
def current_head
|
|
42
57
|
git( "rev-parse", "HEAD" ).first.strip
|
|
43
58
|
end
|
|
44
59
|
|
|
45
|
-
#
|
|
60
|
+
# What the production standard is called.
|
|
46
61
|
def main_label
|
|
47
62
|
@main_label
|
|
48
63
|
end
|
|
49
64
|
|
|
50
|
-
# The bureau's address
|
|
65
|
+
# The bureau's address — where to send things.
|
|
51
66
|
def bureau_address
|
|
52
67
|
@bureau_address
|
|
53
68
|
end
|
|
54
69
|
|
|
55
|
-
# Is the
|
|
70
|
+
# Is the floor clean? No loose material lying around.
|
|
56
71
|
def clean?
|
|
57
72
|
output, _, status = git( "status", "--porcelain" )
|
|
58
73
|
status.success? && output.strip.empty?
|
|
@@ -60,47 +75,36 @@ module Carson
|
|
|
60
75
|
|
|
61
76
|
# --- Warehouse operations ---
|
|
62
77
|
|
|
63
|
-
# Ship a parcel to the
|
|
64
|
-
# The warehouse sends the parcel's label to the remote.
|
|
78
|
+
# Ship a parcel to the backup so the courier can work with it.
|
|
65
79
|
def ship( parcel, remote: bureau_address )
|
|
66
80
|
_, _, status = git( "push", "-u", remote, parcel.label )
|
|
67
81
|
status.success?
|
|
68
82
|
end
|
|
69
83
|
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
_, _, status = git( *arguments )
|
|
84
|
+
# Is this parcel based on the latest standard?
|
|
85
|
+
# The standard is vault state — is the parcel built on top of it?
|
|
86
|
+
def based_on_latest?( parcel )
|
|
87
|
+
standard = "#{bureau_address}/#{main_label}"
|
|
88
|
+
_, _, status = git( "merge-base", "--is-ancestor", standard, parcel.head )
|
|
76
89
|
status.success?
|
|
77
90
|
end
|
|
78
91
|
|
|
79
|
-
#
|
|
80
|
-
# Checks whether the registry tip is an ancestor of the parcel's head.
|
|
81
|
-
def based_on_latest_standard?( parcel, registry: "#{bureau_address}/#{main_label}" )
|
|
82
|
-
_, _, status = git( "merge-base", "--is-ancestor", registry, parcel.head )
|
|
83
|
-
status.success?
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Ensure the warehouse complies with company standards (template sync).
|
|
87
|
-
# Delegates to the injected compliance checker. If no checker is set,
|
|
88
|
-
# the warehouse assumes compliance — no templates to enforce.
|
|
92
|
+
# Submit compliance — ensure the warehouse meets company standards.
|
|
89
93
|
def submit_compliance!
|
|
90
94
|
return { compliant: true, committed: false } unless @compliance_checker
|
|
91
95
|
|
|
92
96
|
@compliance_checker.call( self )
|
|
93
97
|
end
|
|
94
98
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
def
|
|
98
|
-
_, _, status = git( "rebase",
|
|
99
|
+
# Rebase a workbench onto the latest standard.
|
|
100
|
+
# When a parcel falls behind the standard, the warehouse fixes it.
|
|
101
|
+
def rebase!( standard: "#{bureau_address}/#{main_label}" )
|
|
102
|
+
_, _, status = git( "rebase", standard )
|
|
99
103
|
status.success?
|
|
100
104
|
end
|
|
101
105
|
|
|
102
|
-
# Pack a parcel — stage all
|
|
103
|
-
# Refuses if the workbench is sealed
|
|
106
|
+
# Pack a parcel — stage all loose material and seal it.
|
|
107
|
+
# Refuses if the workbench is sealed — a parcel is already in flight.
|
|
104
108
|
def pack!( message: )
|
|
105
109
|
if sealed?
|
|
106
110
|
raise "Branch is locked — PR ##{sealed_tracking_number} in flight. " \
|
|
@@ -111,14 +115,14 @@ module Carson
|
|
|
111
115
|
status.success?
|
|
112
116
|
end
|
|
113
117
|
|
|
114
|
-
# Receive the latest standard
|
|
115
|
-
#
|
|
118
|
+
# Receive the latest standard.
|
|
119
|
+
# After a parcel is accepted, the standard has changed. The warehouse
|
|
120
|
+
# updates its vault without disturbing the current workbench.
|
|
116
121
|
#
|
|
117
|
-
# Two paths depending on the
|
|
122
|
+
# Two paths depending on the vault's checkout state:
|
|
118
123
|
# - Main checked out → merge --ff-only (updates ref + working tree).
|
|
119
|
-
# - Main not checked out → fetch refspec (updates ref only
|
|
120
|
-
|
|
121
|
-
def receive_latest_standard!( remote: bureau_address )
|
|
124
|
+
# - Main not checked out → fetch refspec (updates ref only).
|
|
125
|
+
def receive_latest!( remote: bureau_address )
|
|
122
126
|
root = main_worktree_root
|
|
123
127
|
|
|
124
128
|
_, _, fetch_status = Open3.capture3( "git", "-C", root, "fetch", remote )
|
|
@@ -145,73 +149,60 @@ module Carson
|
|
|
145
149
|
# --- Delivery prep ---
|
|
146
150
|
|
|
147
151
|
# Prepare a parcel for delivery.
|
|
148
|
-
#
|
|
149
|
-
#
|
|
152
|
+
# Packs if the agent provided a message, checks if the parcel is based
|
|
153
|
+
# on the latest standard, rebases automatically if it's behind.
|
|
150
154
|
def prepare!( parcel, message: nil )
|
|
151
|
-
|
|
155
|
+
standard = "#{bureau_address}/#{main_label}"
|
|
152
156
|
|
|
153
|
-
# Pack if the agent provided a commit message.
|
|
154
157
|
if message
|
|
155
158
|
unless pack!( message: message )
|
|
156
159
|
return { status: "error", error: "Nothing to commit.", recovery: "Stage changes first." }
|
|
157
160
|
end
|
|
158
|
-
# Update the parcel's head after packing.
|
|
159
161
|
parcel = Parcel.new( label: parcel.label, head: current_head )
|
|
160
162
|
end
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
unless fetch_latest
|
|
164
|
+
unless receive_latest!
|
|
164
165
|
return {
|
|
165
166
|
status: "block",
|
|
166
|
-
error: "Cannot
|
|
167
|
+
error: "Cannot receive latest standard.",
|
|
167
168
|
recovery: "Check network and remote config, then deliver again."
|
|
168
169
|
}
|
|
169
170
|
end
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
# Auto-rebase onto the latest standard.
|
|
174
|
-
unless rebase_on_latest_standard!( registry: registry )
|
|
172
|
+
unless based_on_latest?( parcel )
|
|
173
|
+
unless rebase!( standard: standard )
|
|
175
174
|
return {
|
|
176
175
|
status: "block",
|
|
177
176
|
error: "#{parcel.label} conflicts with #{@main_label}.",
|
|
178
177
|
recovery: "Rebase onto #{@main_label}, resolve conflicts, deliver again."
|
|
179
178
|
}
|
|
180
179
|
end
|
|
181
|
-
# Update the parcel's head after rebase.
|
|
182
180
|
parcel = Parcel.new( label: parcel.label, head: current_head )
|
|
183
181
|
end
|
|
184
182
|
|
|
183
|
+
# Stamp the parcel with its origin so it knows whether it carries anything.
|
|
184
|
+
origin, = git( "merge-base", main_label, parcel.label )
|
|
185
|
+
parcel = Parcel.new( label: parcel.label, head: parcel.head, shelf: parcel.shelf, origin: origin.strip )
|
|
186
|
+
|
|
187
|
+
if parcel.empty?
|
|
188
|
+
return {
|
|
189
|
+
status: "block",
|
|
190
|
+
error: "Nothing to deliver — no commits ahead of #{main_label}.",
|
|
191
|
+
recovery: "Commit changes, then carson deliver."
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
185
195
|
{ status: "ok", parcel: parcel }
|
|
186
196
|
end
|
|
187
197
|
|
|
188
198
|
# --- Inventory ---
|
|
189
199
|
|
|
190
|
-
# All labels
|
|
200
|
+
# All labels in the warehouse.
|
|
191
201
|
def labels
|
|
192
202
|
output, = git( "branch", "--format", "%(refname:short)" )
|
|
193
203
|
output.lines.map { it.strip }.reject { it.empty? }
|
|
194
204
|
end
|
|
195
205
|
|
|
196
|
-
# Has this label's content been absorbed into main?
|
|
197
|
-
# Content-aware: compares tree content, not SHA ancestry.
|
|
198
|
-
# Catches rebase-merged and squash-merged branches that
|
|
199
|
-
# `git branch --merged` misses (replayed SHAs differ).
|
|
200
|
-
def label_absorbed?( name )
|
|
201
|
-
_, _, status = Open3.capture3(
|
|
202
|
-
"git", "diff", "--quiet", @main_label, name,
|
|
203
|
-
chdir: path )
|
|
204
|
-
status.success?
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# All worktree paths (transitional — use workbenches for Worktree instances).
|
|
208
|
-
def shelves
|
|
209
|
-
output, = git( "worktree", "list", "--porcelain" )
|
|
210
|
-
output.lines
|
|
211
|
-
.select { it.start_with?( "worktree " ) }
|
|
212
|
-
.map { it.sub( "worktree ", "" ).strip }
|
|
213
|
-
end
|
|
214
|
-
|
|
215
206
|
# The main warehouse location — resolves correctly even from a workbench.
|
|
216
207
|
def main_worktree_root
|
|
217
208
|
git_common_dir, = git( "rev-parse", "--path-format=absolute", "--git-common-dir" )
|
data/lib/carson/worktree.rb
CHANGED
|
@@ -279,7 +279,7 @@ module Carson
|
|
|
279
279
|
resolved_path: resolved_path,
|
|
280
280
|
branch: branch,
|
|
281
281
|
error: "current working directory is inside this worktree",
|
|
282
|
-
recovery: "cd #{safe_root} && carson
|
|
282
|
+
recovery: "cd #{safe_root} && carson checkout #{File.basename( resolved_path )}"
|
|
283
283
|
}
|
|
284
284
|
end
|
|
285
285
|
|