carson 4.1.1 → 4.1.2
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 +6 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +1 -1
- data/lib/carson/runtime/abandon.rb +6 -5
- data/lib/carson/runtime/local/hooks.rb +1 -1
- data/lib/carson/runtime/local/worktree.rb +81 -7
- data/lib/carson/runtime/recover.rb +2 -2
- data/lib/carson/warehouse/bureau.rb +117 -0
- data/lib/carson/warehouse/seal.rb +55 -0
- data/lib/carson/warehouse/workbench.rb +469 -0
- data/lib/carson/warehouse.rb +28 -180
- data/lib/carson/worktree.rb +10 -0
- metadata +9 -6
- /data/config/{.github/hooks → hooks}/command-guard +0 -0
- /data/config/{.github/hooks → hooks}/pre-commit +0 -0
- /data/config/{.github/hooks → hooks}/pre-merge-commit +0 -0
- /data/config/{.github/hooks → hooks}/pre-push +0 -0
- /data/config/{.github/hooks → hooks}/prepare-commit-msg +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c882d441c7ea190db45ec65d5aad771de7a601c19375204dfabaa6ec285a6ebf
|
|
4
|
+
data.tar.gz: 6b7cc35fa68c90a5d493409f8bbc7799dd1cc2adacb2af0ff15cf42393d66c9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c28c1b696376b7949f505481c22908dabc8c4c813ae71b9d461e11d1298143e89fa4490a0f6a00a7a4730880aa0b5b3d9916559dfa3181d595bf181c3e62eca8
|
|
7
|
+
data.tar.gz: c202d344de6b324aae1e9dc9d8ad31a7378c6404c248daca3084d919caa5e21068cc64a6bf38d0e50deccd41e88ee5c0680dac51702410469f6906b5f35903f9
|
data/RELEASE.md
CHANGED
|
@@ -7,6 +7,12 @@ Release-note scope rule:
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 4.1.2
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Hook templates misplaced under `.github/`** — `config/.github/hooks/` moved to `config/hooks/`. Git hooks are local mechanisms installed to `~/.carson/hooks/`, not GitHub configuration. The previous path falsely implied a relationship with GitHub's `.github/` convention.
|
|
15
|
+
|
|
10
16
|
## 4.1.1
|
|
11
17
|
|
|
12
18
|
### Fixed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.1.
|
|
1
|
+
4.1.2
|
data/lib/carson/cli.rb
CHANGED
|
@@ -898,7 +898,7 @@ module Carson
|
|
|
898
898
|
# referenced by Claude Code's PreToolUse hook. It must exist regardless of
|
|
899
899
|
# whether `carson refresh` has been run in any governed repo.
|
|
900
900
|
def self.ensure_global_artefacts!( tool_root: )
|
|
901
|
-
source = File.join( tool_root, "config", "
|
|
901
|
+
source = File.join( tool_root, "config", "hooks", "command-guard" )
|
|
902
902
|
return unless File.file?( source )
|
|
903
903
|
|
|
904
904
|
hooks_base = File.expand_path( "~/.carson/hooks" )
|
|
@@ -140,17 +140,18 @@ module Carson
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
if worktree
|
|
143
|
-
check =
|
|
143
|
+
check = worktree_warehouse.assess_teardown( worktree, force: false, skip_unpushed: true )
|
|
144
144
|
return nil if check.fetch( :status ) == :ok
|
|
145
145
|
|
|
146
|
-
recovery = check
|
|
147
|
-
if check
|
|
146
|
+
recovery = check[ :recovery ]
|
|
147
|
+
if check[ :error ] == "worktree has uncommitted changes"
|
|
148
148
|
recovery = "commit or discard the changes, then retry carson abandon #{branch}"
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
+
exit_code = check[ :status ] == :block ? EXIT_BLOCK : EXIT_ERROR
|
|
151
152
|
return {
|
|
152
|
-
exit_code:
|
|
153
|
-
error: check
|
|
153
|
+
exit_code: exit_code,
|
|
154
|
+
error: check[ :error ],
|
|
154
155
|
recovery: recovery
|
|
155
156
|
}
|
|
156
157
|
end
|
|
@@ -64,7 +64,7 @@ module Carson
|
|
|
64
64
|
|
|
65
65
|
# Canonical hook template location inside Carson repository.
|
|
66
66
|
def hook_template_path( hook_name: )
|
|
67
|
-
File.join( tool_root, "config", "
|
|
67
|
+
File.join( tool_root, "config", "hooks", hook_name )
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# Reports full hook health and can enforce stricter action messaging in `check`.
|
|
@@ -1,31 +1,46 @@
|
|
|
1
1
|
# Thin worktree delegate layer on Runtime.
|
|
2
|
-
# Lifecycle operations
|
|
3
|
-
#
|
|
4
|
-
# CWD branch detection).
|
|
2
|
+
# Lifecycle operations delegate to Warehouse::Workbench.
|
|
3
|
+
# Keeps only methods that genuinely belong on Runtime (path resolution,
|
|
4
|
+
# CWD branch detection, output rendering).
|
|
5
5
|
module Carson
|
|
6
6
|
class Runtime
|
|
7
7
|
module Local
|
|
8
8
|
|
|
9
|
-
# --- Delegates to
|
|
9
|
+
# --- Delegates to Warehouse::Workbench ---
|
|
10
10
|
|
|
11
11
|
# Creates a new worktree under .claude/worktrees/<name>.
|
|
12
12
|
def worktree_create!( name:, json_output: false )
|
|
13
|
-
|
|
13
|
+
result = worktree_warehouse.build_workbench!( name: name )
|
|
14
|
+
finish_worktree( result: result, json_output: json_output )
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# Removes a worktree: directory, git registration, and branch.
|
|
17
18
|
def worktree_remove!( worktree_path:, force: false, skip_unpushed: false, json_output: false )
|
|
18
|
-
|
|
19
|
+
wh = worktree_warehouse
|
|
20
|
+
workbench = wh.workbench_named( worktree_path )
|
|
21
|
+
unless workbench
|
|
22
|
+
return finish_worktree(
|
|
23
|
+
result: { command: "worktree remove", status: "error",
|
|
24
|
+
name: File.basename( worktree_path ),
|
|
25
|
+
error: "#{worktree_path} is not a registered worktree",
|
|
26
|
+
recovery: "git worktree list" },
|
|
27
|
+
json_output: json_output )
|
|
28
|
+
end
|
|
29
|
+
result = wh.tear_down_workbench!( workbench, force: force, skip_unpushed: skip_unpushed )
|
|
30
|
+
finish_worktree( result: result, json_output: json_output )
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
# Removes agent-owned worktrees whose branch content is already on main.
|
|
34
|
+
# Still uses Worktree.sweep_stale! which needs classify_worktree_cleanup
|
|
35
|
+
# on Runtime. Migrates to warehouse.sweep_workbenches! when housekeep
|
|
36
|
+
# is absorbed (Phase 4 item 40).
|
|
22
37
|
def sweep_stale_worktrees!
|
|
23
38
|
Worktree.sweep_stale!( runtime: self )
|
|
24
39
|
end
|
|
25
40
|
|
|
26
41
|
# Returns all registered worktrees as Carson::Worktree instances.
|
|
27
42
|
def worktree_list
|
|
28
|
-
|
|
43
|
+
worktree_warehouse.workbenches
|
|
29
44
|
end
|
|
30
45
|
|
|
31
46
|
# Human and JSON status surface for all registered worktrees.
|
|
@@ -109,6 +124,65 @@ module Carson
|
|
|
109
124
|
|
|
110
125
|
private
|
|
111
126
|
|
|
127
|
+
# Build a Warehouse for workbench operations.
|
|
128
|
+
# Always rooted at the main worktree so workbench management
|
|
129
|
+
# works correctly even when called from inside a worktree.
|
|
130
|
+
def worktree_warehouse
|
|
131
|
+
Warehouse.new(
|
|
132
|
+
path: main_worktree_root,
|
|
133
|
+
main_label: config.main_branch,
|
|
134
|
+
bureau_address: config.git_remote
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Render a workbench operation result as JSON or human text.
|
|
139
|
+
# Returns the exit code for CLI dispatch.
|
|
140
|
+
def finish_worktree( result:, json_output: false )
|
|
141
|
+
exit_code = result.fetch( :exit_code, nil )
|
|
142
|
+
status = result[ :status ]
|
|
143
|
+
|
|
144
|
+
# Derive exit code from status if not explicitly set.
|
|
145
|
+
exit_code ||= case status
|
|
146
|
+
when "ok" then EXIT_OK
|
|
147
|
+
when "block" then EXIT_BLOCK
|
|
148
|
+
else EXIT_ERROR
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
result[ :exit_code ] = exit_code
|
|
152
|
+
|
|
153
|
+
if json_output
|
|
154
|
+
output.puts JSON.pretty_generate( result )
|
|
155
|
+
else
|
|
156
|
+
print_worktree_result( result: result )
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
exit_code
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Human-readable output for worktree operation results.
|
|
163
|
+
def print_worktree_result( result: )
|
|
164
|
+
command = result[ :command ]
|
|
165
|
+
status = result[ :status ]
|
|
166
|
+
|
|
167
|
+
case status
|
|
168
|
+
when "ok"
|
|
169
|
+
case command
|
|
170
|
+
when "worktree create"
|
|
171
|
+
puts_line "Worktree created: #{result[ :name ]}"
|
|
172
|
+
puts_line " Path: #{result[ :path ]}"
|
|
173
|
+
puts_line " Branch: #{result[ :branch ]}"
|
|
174
|
+
when "worktree remove"
|
|
175
|
+
puts_line "Worktree removed: #{result[ :name ]}" unless verbose?
|
|
176
|
+
end
|
|
177
|
+
when "error"
|
|
178
|
+
puts_line result[ :error ]
|
|
179
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
180
|
+
when "block"
|
|
181
|
+
puts_line "#{result[ :error ]&.capitalize || 'Held'}: #{result[ :name ]}"
|
|
182
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
112
186
|
def worktree_inventory
|
|
113
187
|
worktree_list.map { |worktree| worktree_inventory_entry( worktree: worktree ) }
|
|
114
188
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
module Carson
|
|
3
3
|
class Runtime
|
|
4
4
|
module Recover
|
|
5
|
-
GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ config
|
|
5
|
+
GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ config/hooks/ ].freeze
|
|
6
6
|
|
|
7
7
|
def recover!( check_name:, json_output: false )
|
|
8
8
|
result = {
|
|
@@ -71,7 +71,7 @@ module Carson
|
|
|
71
71
|
|
|
72
72
|
unless relation.fetch( :related )
|
|
73
73
|
result[ :error ] = "branch does not touch the governance surface for #{check_name}"
|
|
74
|
-
result[ :recovery ] = "update the branch to repair .github/ or config
|
|
74
|
+
result[ :recovery ] = "update the branch to repair .github/ or config/hooks/, then rerun carson recover --check #{check_name.inspect}"
|
|
75
75
|
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# The warehouse's bureau-facing concern.
|
|
2
|
+
# The warehouse owns the connection to the bureau (GitHub).
|
|
3
|
+
# It queries, files, and registers on behalf of the courier.
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Carson
|
|
7
|
+
class Warehouse
|
|
8
|
+
module Bureau
|
|
9
|
+
|
|
10
|
+
# Check the parcel's status at the bureau using the waybill.
|
|
11
|
+
# Calls gh pr view + gh pr checks. Records findings onto the waybill.
|
|
12
|
+
def check_parcel_at_bureau_with( waybill )
|
|
13
|
+
state = fetch_pr_state_for( waybill.tracking_number )
|
|
14
|
+
ci, ci_diagnostic = fetch_ci_state_for( waybill.tracking_number )
|
|
15
|
+
waybill.record( state: state, ci: ci, ci_diagnostic: ci_diagnostic )
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# File a waybill at the bureau for this parcel.
|
|
19
|
+
# Calls gh pr create. Returns a Waybill with tracking number, or nil on failure.
|
|
20
|
+
def file_waybill_for!( parcel, title: nil, body_file: nil )
|
|
21
|
+
filing_title = title || Waybill.default_title_for( parcel.label )
|
|
22
|
+
arguments = [ "pr", "create", "--title", filing_title, "--head", parcel.label ]
|
|
23
|
+
|
|
24
|
+
if body_file && File.exist?( body_file )
|
|
25
|
+
arguments.push( "--body-file", body_file )
|
|
26
|
+
else
|
|
27
|
+
arguments.push( "--body", "" )
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
stdout, _, status = gh( *arguments )
|
|
31
|
+
tracking_number = nil
|
|
32
|
+
url = nil
|
|
33
|
+
|
|
34
|
+
if status.success?
|
|
35
|
+
url = stdout.to_s.strip
|
|
36
|
+
tracking_number = url.split( "/" ).last.to_i
|
|
37
|
+
tracking_number = nil if tracking_number == 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# If create failed or returned no number, try to find an existing PR.
|
|
41
|
+
unless tracking_number
|
|
42
|
+
tracking_number, url = find_existing_waybill_for( parcel.label )
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return nil unless tracking_number
|
|
46
|
+
|
|
47
|
+
Waybill.new( label: parcel.label, tracking_number: tracking_number, url: url )
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Register the parcel at the bureau using the waybill.
|
|
51
|
+
# Calls gh pr merge. Stamps the waybill on success.
|
|
52
|
+
def register_parcel_at_bureau_with!( waybill, method: )
|
|
53
|
+
_, _, status = gh( "pr", "merge", waybill.tracking_number.to_s, "--#{method}" )
|
|
54
|
+
if status.success?
|
|
55
|
+
waybill.stamp( :accepted )
|
|
56
|
+
else
|
|
57
|
+
# Re-check the state — the merge may have revealed a new blocker.
|
|
58
|
+
check_parcel_at_bureau_with( waybill )
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Fetch PR state from the bureau for a tracking number.
|
|
65
|
+
# Returns the parsed state hash, or nil on failure.
|
|
66
|
+
def fetch_pr_state_for( tracking_number )
|
|
67
|
+
stdout, _, status = gh(
|
|
68
|
+
"pr", "view", tracking_number.to_s,
|
|
69
|
+
"--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
|
|
70
|
+
)
|
|
71
|
+
return nil unless status.success?
|
|
72
|
+
|
|
73
|
+
JSON.parse( stdout )
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fetch CI state from the bureau for a tracking number.
|
|
79
|
+
# Returns [ci_symbol, diagnostic_or_nil].
|
|
80
|
+
# Captures the first line of stderr as diagnostic when the command fails.
|
|
81
|
+
def fetch_ci_state_for( tracking_number )
|
|
82
|
+
stdout, stderr, status = gh(
|
|
83
|
+
"pr", "checks", tracking_number.to_s,
|
|
84
|
+
"--json", "name,bucket"
|
|
85
|
+
)
|
|
86
|
+
unless status.success?
|
|
87
|
+
return [ :error, stderr.to_s.strip.lines.first&.strip ]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
checks = JSON.parse( stdout ) rescue []
|
|
91
|
+
return [ :none, nil ] if checks.empty?
|
|
92
|
+
|
|
93
|
+
buckets = checks.map { it[ "bucket" ].to_s.downcase }
|
|
94
|
+
return [ :fail, nil ] if buckets.include?( "fail" )
|
|
95
|
+
return [ :pending, nil ] if buckets.include?( "pending" )
|
|
96
|
+
|
|
97
|
+
[ :pass, nil ]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Try to find an existing PR for this label at the bureau.
|
|
101
|
+
# Returns [tracking_number, url] or [nil, nil].
|
|
102
|
+
def find_existing_waybill_for( label )
|
|
103
|
+
stdout, _, status = gh(
|
|
104
|
+
"pr", "view", label,
|
|
105
|
+
"--json", "number,url,state"
|
|
106
|
+
)
|
|
107
|
+
if status.success?
|
|
108
|
+
data = JSON.parse( stdout ) rescue nil
|
|
109
|
+
if data && data[ "number" ] && data[ "state" ] == "OPEN"
|
|
110
|
+
return [ data[ "number" ], data[ "url" ].to_s ]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
[ nil, nil ]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# The warehouse's workbench seal concern.
|
|
2
|
+
# Once a parcel ships and the waybill is filed, the warehouse seals the
|
|
3
|
+
# workbench. No more packing until the delivery outcome is confirmed.
|
|
4
|
+
# The seal marker lives outside the worktree (~/.carson/seals/) so it
|
|
5
|
+
# does not pollute git status.
|
|
6
|
+
require "digest"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module Carson
|
|
10
|
+
class Warehouse
|
|
11
|
+
module Seal
|
|
12
|
+
|
|
13
|
+
# Seal the workbench — no more packing until delivery outcome is confirmed.
|
|
14
|
+
# The courier seals the workbench after shipping and filing the waybill.
|
|
15
|
+
def seal_workbench!( tracking_number: )
|
|
16
|
+
marker = delivering_marker_path
|
|
17
|
+
FileUtils.mkdir_p( File.dirname( marker ) )
|
|
18
|
+
File.write( marker, "#{tracking_number}\n#{@path}" )
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Unseal the workbench — the courier brought back the parcel.
|
|
22
|
+
# Called when the delivery outcome is held or rejected.
|
|
23
|
+
def unseal_workbench!
|
|
24
|
+
File.delete( delivering_marker_path ) if File.exist?( delivering_marker_path )
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Is this workbench sealed for a delivery in flight?
|
|
28
|
+
def sealed?
|
|
29
|
+
File.exist?( delivering_marker_path )
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The tracking number of the in-flight delivery (nil if not sealed).
|
|
33
|
+
def sealed_tracking_number
|
|
34
|
+
return nil unless sealed?
|
|
35
|
+
File.read( delivering_marker_path ).lines.first.strip
|
|
36
|
+
end
|
|
37
|
+
|
|
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
|
+
private
|
|
44
|
+
|
|
45
|
+
# Path to the delivery marker file.
|
|
46
|
+
# Lives outside the worktree at ~/.carson/seals/<sha256-of-path>
|
|
47
|
+
# so it does not pollute git status.
|
|
48
|
+
def delivering_marker_path
|
|
49
|
+
seals_dir = File.join( Dir.home, ".carson", "seals" )
|
|
50
|
+
key = Digest::SHA256.hexdigest( @path )
|
|
51
|
+
File.join( seals_dir, key )
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# The warehouse's workbench concern.
|
|
2
|
+
# Builds, tears down, sweeps, and inventories workbenches.
|
|
3
|
+
# Workbenches are passive objects — the warehouse acts on them.
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module Carson
|
|
9
|
+
class Warehouse
|
|
10
|
+
module Workbench
|
|
11
|
+
|
|
12
|
+
# Agent directory names whose workbenches the warehouse may sweep.
|
|
13
|
+
AGENT_DIRS = %w[ .claude .codex ].freeze
|
|
14
|
+
|
|
15
|
+
# --- Inventory ---
|
|
16
|
+
|
|
17
|
+
# All workbenches in this warehouse.
|
|
18
|
+
# Parses the git worktree registry into Worktree instances.
|
|
19
|
+
# Normalises paths with realpath so comparisons work across symlinks.
|
|
20
|
+
def workbenches
|
|
21
|
+
raw, = git( "worktree", "list", "--porcelain" )
|
|
22
|
+
entries = []
|
|
23
|
+
current_path = nil
|
|
24
|
+
current_branch = :unset
|
|
25
|
+
current_prunable_reason = nil
|
|
26
|
+
|
|
27
|
+
raw.lines.each do |line|
|
|
28
|
+
line = line.strip
|
|
29
|
+
if line.empty?
|
|
30
|
+
entries << Carson::Worktree.new(
|
|
31
|
+
path: current_path,
|
|
32
|
+
branch: current_branch == :unset ? nil : current_branch,
|
|
33
|
+
prunable_reason: current_prunable_reason
|
|
34
|
+
) if current_path
|
|
35
|
+
current_path = nil
|
|
36
|
+
current_branch = :unset
|
|
37
|
+
current_prunable_reason = nil
|
|
38
|
+
elsif line.start_with?( "worktree " )
|
|
39
|
+
current_path = realpath_safe( line.sub( "worktree ", "" ) )
|
|
40
|
+
elsif line.start_with?( "branch " )
|
|
41
|
+
current_branch = line.sub( "branch refs/heads/", "" )
|
|
42
|
+
elsif line == "detached"
|
|
43
|
+
current_branch = nil
|
|
44
|
+
elsif line.start_with?( "prunable" )
|
|
45
|
+
reason = line.sub( "prunable", "" ).strip
|
|
46
|
+
current_prunable_reason = reason.empty? ? "prunable" : reason
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Handle the last entry (porcelain output may not end with a blank line).
|
|
51
|
+
entries << Carson::Worktree.new(
|
|
52
|
+
path: current_path,
|
|
53
|
+
branch: current_branch == :unset ? nil : current_branch,
|
|
54
|
+
prunable_reason: current_prunable_reason
|
|
55
|
+
) if current_path
|
|
56
|
+
|
|
57
|
+
entries
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Find a workbench by canonical path.
|
|
61
|
+
def workbench_at( path: )
|
|
62
|
+
canonical = realpath_safe( path )
|
|
63
|
+
workbenches.find { |wb| wb.path == canonical }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolve a bare name and find the workbench.
|
|
67
|
+
# Tries .claude/worktrees/<name> first, then searches all registered
|
|
68
|
+
# workbenches by directory name.
|
|
69
|
+
def workbench_named( name )
|
|
70
|
+
if Pathname.new( name ).absolute?
|
|
71
|
+
return workbench_at( path: name )
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Try as a relative path from CWD.
|
|
75
|
+
relative_candidate = realpath_safe( File.expand_path( name, Dir.pwd ) )
|
|
76
|
+
found = workbench_at( path: relative_candidate )
|
|
77
|
+
return found if found
|
|
78
|
+
|
|
79
|
+
# Try scoped path (e.g. "claude/foo" → .claude/worktrees/claude/foo).
|
|
80
|
+
if name.include?( "/" )
|
|
81
|
+
scoped_candidate = realpath_safe( File.join( main_worktree_root, ".claude", "worktrees", name ) )
|
|
82
|
+
found = workbench_at( path: scoped_candidate )
|
|
83
|
+
return found if found
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Try flat layout: .claude/worktrees/<name>.
|
|
87
|
+
root = main_worktree_root
|
|
88
|
+
candidate = realpath_safe( File.join( root, ".claude", "worktrees", name ) )
|
|
89
|
+
found = workbench_at( path: candidate )
|
|
90
|
+
return found if found
|
|
91
|
+
|
|
92
|
+
# Search all registered workbenches by dirname.
|
|
93
|
+
matches = workbenches.select { |wb| File.basename( wb.path ) == name }
|
|
94
|
+
return matches.first if matches.size == 1
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Is this path a registered workbench?
|
|
100
|
+
def workbench_registered?( path: )
|
|
101
|
+
canonical = realpath_safe( path )
|
|
102
|
+
workbenches.any? { |wb| wb.path == canonical }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Lifecycle ---
|
|
106
|
+
|
|
107
|
+
# Build a new workbench from the latest production standard.
|
|
108
|
+
# Creates the directory, branches from the latest standard,
|
|
109
|
+
# ensures .claude/ is excluded from git status.
|
|
110
|
+
def build_workbench!( name: )
|
|
111
|
+
root = main_worktree_root
|
|
112
|
+
worktrees_dir = File.join( root, ".claude", "worktrees" )
|
|
113
|
+
workbench_path = File.join( worktrees_dir, name )
|
|
114
|
+
|
|
115
|
+
if Dir.exist?( workbench_path )
|
|
116
|
+
return { command: "worktree create", status: "error", name: name,
|
|
117
|
+
path: workbench_path,
|
|
118
|
+
error: "worktree already exists: #{name}",
|
|
119
|
+
recovery: "carson worktree remove #{name}, then retry" }
|
|
120
|
+
end
|
|
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
|
+
# Ensure .claude/ is excluded from git status.
|
|
134
|
+
ensure_claude_dir_excluded!
|
|
135
|
+
|
|
136
|
+
# Create the worktree with a new branch.
|
|
137
|
+
FileUtils.mkdir_p( File.dirname( workbench_path ) )
|
|
138
|
+
wt_stdout, wt_stderr, wt_status = git( "worktree", "add", workbench_path, "-b", name, base )
|
|
139
|
+
unless wt_status.success?
|
|
140
|
+
error_text = wt_stderr.to_s.strip
|
|
141
|
+
error_text = "unable to create worktree" if error_text.empty?
|
|
142
|
+
return { command: "worktree create", status: "error", name: name,
|
|
143
|
+
error: error_text }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Verify the build succeeded.
|
|
147
|
+
unless workbench_creation_verified?( path: workbench_path, branch: name )
|
|
148
|
+
diagnostics = gather_build_diagnostics(
|
|
149
|
+
git_stdout: wt_stdout, git_stderr: wt_stderr, name: name )
|
|
150
|
+
cleanup_partial_build!( path: workbench_path, branch: name )
|
|
151
|
+
return { command: "worktree create", status: "error", name: name,
|
|
152
|
+
path: workbench_path, branch: name,
|
|
153
|
+
error: "git reported success but Carson could not verify the worktree and branch",
|
|
154
|
+
recovery: "git worktree list --porcelain && git branch --list '#{name}'",
|
|
155
|
+
diagnostics: diagnostics }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
{ command: "worktree create", status: "ok", name: name,
|
|
159
|
+
path: workbench_path, branch: name }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Tear down a workbench — directory, registration, label.
|
|
163
|
+
# The warehouse checks safety before acting.
|
|
164
|
+
def tear_down_workbench!( workbench, force: false, skip_unpushed: false )
|
|
165
|
+
# If the directory is already gone, repair the stale registration.
|
|
166
|
+
unless workbench.exists?
|
|
167
|
+
return repair_missing_workbench!( workbench )
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Safety assessment.
|
|
171
|
+
assessment = assess_teardown( workbench, force: force, skip_unpushed: skip_unpushed )
|
|
172
|
+
unless assessment[ :status ] == :ok
|
|
173
|
+
return { command: "worktree remove", status: assessment[ :result_status ] || "error",
|
|
174
|
+
name: File.basename( workbench.path ), branch: workbench.branch,
|
|
175
|
+
error: assessment[ :error ], recovery: assessment[ :recovery ] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Step 1: remove the worktree (directory + git registration).
|
|
179
|
+
rm_args = [ "worktree", "remove" ]
|
|
180
|
+
rm_args << "--force" if force
|
|
181
|
+
rm_args << workbench.path
|
|
182
|
+
_, rm_stderr, rm_status = git( *rm_args )
|
|
183
|
+
unless rm_status.success?
|
|
184
|
+
error_text = rm_stderr.to_s.strip
|
|
185
|
+
error_text = "unable to remove worktree" if error_text.empty?
|
|
186
|
+
if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
|
|
187
|
+
return { command: "worktree remove", status: "error",
|
|
188
|
+
name: File.basename( workbench.path ),
|
|
189
|
+
error: "worktree has uncommitted changes",
|
|
190
|
+
recovery: "commit or discard changes first, or use --force to override" }
|
|
191
|
+
end
|
|
192
|
+
return { command: "worktree remove", status: "error",
|
|
193
|
+
name: File.basename( workbench.path ), error: error_text }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Step 2: delete the local branch.
|
|
197
|
+
branch = workbench.branch
|
|
198
|
+
branch_deleted = false
|
|
199
|
+
if branch
|
|
200
|
+
_, _, del_ok = git( "branch", "-D", branch )
|
|
201
|
+
branch_deleted = del_ok.success?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Step 3: delete the remote branch (best-effort).
|
|
205
|
+
remote_deleted = false
|
|
206
|
+
if branch
|
|
207
|
+
_, _, rd_ok = git( "push", @bureau_address, "--delete", branch )
|
|
208
|
+
remote_deleted = rd_ok.success?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
{ command: "worktree remove", status: "ok",
|
|
212
|
+
name: File.basename( workbench.path ),
|
|
213
|
+
branch: branch, branch_deleted: branch_deleted,
|
|
214
|
+
remote_deleted: remote_deleted }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Full safety assessment before tear-down.
|
|
218
|
+
# Returns { status: :ok } or { status: :block/:error, error:, recovery: }.
|
|
219
|
+
def assess_teardown( workbench, force: false, skip_unpushed: false )
|
|
220
|
+
unless workbench.exists?
|
|
221
|
+
return { status: :ok, missing: true }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
if agent_at_workbench?( workbench )
|
|
225
|
+
return { status: :block, result_status: "block",
|
|
226
|
+
error: "current working directory is inside this worktree",
|
|
227
|
+
recovery: "cd #{main_worktree_root} && carson worktree remove #{File.basename( workbench.path )}" }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
if workbench_held_by_process?( workbench )
|
|
231
|
+
return { status: :block, result_status: "block",
|
|
232
|
+
error: "another process has its working directory inside this worktree",
|
|
233
|
+
recovery: "wait for the other session to finish, then retry" }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if !force && !workbench.clean?
|
|
237
|
+
return { status: :error, result_status: "error",
|
|
238
|
+
error: "worktree has uncommitted changes",
|
|
239
|
+
recovery: "commit or discard changes first, or use --force to override" }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
unless force || skip_unpushed
|
|
243
|
+
unpushed = workbench_has_unpushed_work?( workbench )
|
|
244
|
+
return unpushed if unpushed
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
{ status: :ok, missing: false }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Sweep stale workbenches. Walk all agent-owned workbenches,
|
|
251
|
+
# check state, tear down those safe to reap. Repair missing ones.
|
|
252
|
+
def sweep_workbenches!
|
|
253
|
+
root = main_worktree_root
|
|
254
|
+
|
|
255
|
+
agent_prefixes = AGENT_DIRS.map do |dir|
|
|
256
|
+
full = File.join( root, dir, "worktrees" )
|
|
257
|
+
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
258
|
+
end.compact
|
|
259
|
+
return if agent_prefixes.empty?
|
|
260
|
+
|
|
261
|
+
workbenches.each do |workbench|
|
|
262
|
+
next unless workbench.branch
|
|
263
|
+
next unless agent_prefixes.any? { |prefix| workbench.path.start_with?( prefix ) }
|
|
264
|
+
|
|
265
|
+
# Use the existing classifier if available (transitional).
|
|
266
|
+
if respond_to?( :classify_worktree_cleanup, true )
|
|
267
|
+
classification = classify_worktree_cleanup( worktree: workbench )
|
|
268
|
+
next unless classification.fetch( :action ) == :reap
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
unless workbench.exists?
|
|
272
|
+
repair_missing_workbench!( workbench )
|
|
273
|
+
next
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
_, _, rm_ok = git( "worktree", "remove", workbench.path )
|
|
277
|
+
next unless rm_ok.success?
|
|
278
|
+
|
|
279
|
+
if workbench.branch
|
|
280
|
+
git( "branch", "-D", workbench.branch )
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
# --- Safety checks ---
|
|
288
|
+
|
|
289
|
+
# Is the agent's working directory inside this workbench?
|
|
290
|
+
def agent_at_workbench?( workbench )
|
|
291
|
+
cwd = realpath_safe( Dir.pwd )
|
|
292
|
+
workbench_path = realpath_safe( workbench.path )
|
|
293
|
+
normalised = File.join( workbench_path, "" )
|
|
294
|
+
cwd == workbench_path || cwd.start_with?( normalised )
|
|
295
|
+
rescue StandardError
|
|
296
|
+
false
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Is another process occupying this workbench?
|
|
300
|
+
def workbench_held_by_process?( workbench )
|
|
301
|
+
canonical = realpath_safe( workbench.path )
|
|
302
|
+
return false if canonical.nil? || canonical.empty?
|
|
303
|
+
return false unless Dir.exist?( canonical )
|
|
304
|
+
|
|
305
|
+
stdout, = Open3.capture3( "lsof", "-d", "cwd" )
|
|
306
|
+
return false if stdout.nil? || stdout.empty?
|
|
307
|
+
|
|
308
|
+
normalised = File.join( canonical, "" )
|
|
309
|
+
my_pid = Process.pid
|
|
310
|
+
stdout.lines.drop( 1 ).any? do |line|
|
|
311
|
+
fields = line.strip.split( /\s+/ )
|
|
312
|
+
next false unless fields.length >= 9
|
|
313
|
+
next false if fields[ 1 ].to_i == my_pid
|
|
314
|
+
name = fields[ 8.. ].join( " " )
|
|
315
|
+
name == canonical || name.start_with?( normalised )
|
|
316
|
+
end
|
|
317
|
+
rescue Errno::ENOENT
|
|
318
|
+
false
|
|
319
|
+
rescue StandardError
|
|
320
|
+
false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Would tearing down lose unpushed work?
|
|
324
|
+
# Content-aware: compares tree content vs main, not SHAs.
|
|
325
|
+
# Returns nil if safe, or { status:, error:, recovery: } if blocked.
|
|
326
|
+
def workbench_has_unpushed_work?( workbench )
|
|
327
|
+
branch = workbench.branch
|
|
328
|
+
return nil unless branch
|
|
329
|
+
|
|
330
|
+
remote_ref = "#{@bureau_address}/#{branch}"
|
|
331
|
+
ahead, _, ahead_status = Open3.capture3(
|
|
332
|
+
"git", "rev-list", "--count", "#{remote_ref}..#{branch}",
|
|
333
|
+
chdir: workbench.path )
|
|
334
|
+
|
|
335
|
+
if !ahead_status.success?
|
|
336
|
+
# Remote ref missing. Only block if branch has unique commits vs main.
|
|
337
|
+
unique, _, unique_status = Open3.capture3(
|
|
338
|
+
"git", "rev-list", "--count", "#{@main_label}..#{branch}",
|
|
339
|
+
chdir: workbench.path )
|
|
340
|
+
if unique_status.success? && unique.strip.to_i > 0
|
|
341
|
+
# Content-aware: if diff is empty, work is on main (squash/rebase merged).
|
|
342
|
+
_, _, diff_ok = Open3.capture3(
|
|
343
|
+
"git", "diff", "--quiet", @main_label, branch,
|
|
344
|
+
chdir: workbench.path )
|
|
345
|
+
unless diff_ok.success?
|
|
346
|
+
return { status: :block, result_status: "block",
|
|
347
|
+
error: "branch has not been pushed to #{@bureau_address}",
|
|
348
|
+
recovery: "git -C #{workbench.path} push -u #{@bureau_address} #{branch}, or use --force to override" }
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
elsif ahead.strip.to_i > 0
|
|
352
|
+
return { status: :block, result_status: "block",
|
|
353
|
+
error: "worktree has unpushed commits",
|
|
354
|
+
recovery: "git -C #{workbench.path} push #{@bureau_address} #{branch}, or use --force to override" }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# --- Repair ---
|
|
361
|
+
|
|
362
|
+
# Handle a missing workbench — prune stale registration, clean up label.
|
|
363
|
+
def repair_missing_workbench!( workbench )
|
|
364
|
+
branch = workbench.branch
|
|
365
|
+
git( "worktree", "prune" )
|
|
366
|
+
|
|
367
|
+
branch_deleted = false
|
|
368
|
+
if branch
|
|
369
|
+
_, _, del_ok = git( "branch", "-D", branch )
|
|
370
|
+
branch_deleted = del_ok.success?
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
remote_deleted = false
|
|
374
|
+
if branch
|
|
375
|
+
_, _, rd_ok = git( "push", @bureau_address, "--delete", branch )
|
|
376
|
+
remote_deleted = rd_ok.success?
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
{ command: "worktree remove", status: "ok",
|
|
380
|
+
name: File.basename( workbench.path ),
|
|
381
|
+
branch: branch, branch_deleted: branch_deleted,
|
|
382
|
+
remote_deleted: remote_deleted }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# --- Build helpers ---
|
|
386
|
+
|
|
387
|
+
# Verify the workbench was created correctly.
|
|
388
|
+
def workbench_creation_verified?( path:, branch: )
|
|
389
|
+
entry = workbench_at( path: path )
|
|
390
|
+
return false if entry.nil?
|
|
391
|
+
return false if entry.prunable?
|
|
392
|
+
return false unless Dir.exist?( path )
|
|
393
|
+
|
|
394
|
+
_, _, success = git( "show-ref", "--verify", "--quiet", "refs/heads/#{branch}" )
|
|
395
|
+
success.success?
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Clean up partial state from a failed build.
|
|
399
|
+
def cleanup_partial_build!( path:, branch: )
|
|
400
|
+
FileUtils.rm_rf( path ) if Dir.exist?( path )
|
|
401
|
+
git( "worktree", "prune" )
|
|
402
|
+
_, _, ref_ok = git( "show-ref", "--verify", "--quiet", "refs/heads/#{branch}" )
|
|
403
|
+
git( "branch", "-D", branch ) if ref_ok.success?
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Capture diagnostic state for a build verification failure.
|
|
407
|
+
def gather_build_diagnostics( git_stdout:, git_stderr:, name: )
|
|
408
|
+
root = main_worktree_root
|
|
409
|
+
wt_list, = git( "worktree", "list", "--porcelain" )
|
|
410
|
+
branch_list, = git( "branch", "--list", name )
|
|
411
|
+
git_version, = Open3.capture3( "git", "--version" )
|
|
412
|
+
workbench_path = File.join( root, ".claude", "worktrees", name )
|
|
413
|
+
entry = workbench_at( path: workbench_path )
|
|
414
|
+
{
|
|
415
|
+
git_stdout: git_stdout.to_s.strip,
|
|
416
|
+
git_stderr: git_stderr.to_s.strip,
|
|
417
|
+
main_worktree_root: root,
|
|
418
|
+
worktree_list: wt_list.to_s.strip,
|
|
419
|
+
branch_list: branch_list.to_s.strip,
|
|
420
|
+
git_version: git_version.to_s.strip,
|
|
421
|
+
worktree_directory_exists: Dir.exist?( workbench_path ),
|
|
422
|
+
registered_worktree: !entry.nil?,
|
|
423
|
+
prunable_reason: entry&.prunable_reason
|
|
424
|
+
}
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Ensure .claude/ is in .git/info/exclude.
|
|
428
|
+
def ensure_claude_dir_excluded!
|
|
429
|
+
git_dir = File.join( main_worktree_root, ".git" )
|
|
430
|
+
return unless File.directory?( git_dir )
|
|
431
|
+
|
|
432
|
+
info_dir = File.join( git_dir, "info" )
|
|
433
|
+
exclude_path = File.join( info_dir, "exclude" )
|
|
434
|
+
|
|
435
|
+
FileUtils.mkdir_p( info_dir )
|
|
436
|
+
existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
|
|
437
|
+
return if existing.lines.any? { |line| line.strip == ".claude/" }
|
|
438
|
+
|
|
439
|
+
File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
|
|
440
|
+
rescue StandardError
|
|
441
|
+
# Best-effort — do not block workbench creation.
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Resolve a path to its canonical form, tolerating non-existent paths.
|
|
445
|
+
def realpath_safe( a_path )
|
|
446
|
+
File.realpath( a_path )
|
|
447
|
+
rescue Errno::ENOENT
|
|
448
|
+
expanded = File.expand_path( a_path )
|
|
449
|
+
missing_segments = []
|
|
450
|
+
candidate = expanded
|
|
451
|
+
|
|
452
|
+
until File.exist?( candidate ) || Dir.exist?( candidate )
|
|
453
|
+
parent = File.dirname( candidate )
|
|
454
|
+
break if parent == candidate
|
|
455
|
+
missing_segments.unshift( File.basename( candidate ) )
|
|
456
|
+
candidate = parent
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
base = if File.exist?( candidate ) || Dir.exist?( candidate )
|
|
460
|
+
File.realpath( candidate )
|
|
461
|
+
else
|
|
462
|
+
candidate
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
missing_segments.empty? ? base : File.join( base, *missing_segments )
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
data/lib/carson/warehouse.rb
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
# A governed repository. In the FedEx metaphor, the warehouse is where
|
|
2
|
-
# parcels
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
require "digest"
|
|
2
|
+
# parcels are built on workbenches (worktrees) with labels (branches).
|
|
3
|
+
# Git and gh commands are hidden inside — callers never see git or
|
|
4
|
+
# GitHub terms.
|
|
6
5
|
require "fileutils"
|
|
7
|
-
require "json"
|
|
8
6
|
require "open3"
|
|
9
7
|
|
|
8
|
+
require_relative "warehouse/workbench"
|
|
9
|
+
require_relative "warehouse/seal"
|
|
10
|
+
require_relative "warehouse/bureau"
|
|
11
|
+
|
|
10
12
|
module Carson
|
|
11
|
-
# A governed repository — the warehouse where parcels are
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# A governed repository — the warehouse where parcels are built on
|
|
14
|
+
# workbenches (worktrees) with labels (branches). An intelligent
|
|
15
|
+
# warehouse that manages itself: packing parcels, checking compliance,
|
|
16
|
+
# managing workbenches, and sweeping up.
|
|
15
17
|
class Warehouse
|
|
18
|
+
include Workbench
|
|
19
|
+
include Seal
|
|
20
|
+
include Bureau
|
|
21
|
+
|
|
16
22
|
attr_reader :path
|
|
17
23
|
|
|
18
24
|
def initialize( path:, main_label: "main", bureau_address: "github", compliance_checker: nil )
|
|
@@ -24,12 +30,12 @@ module Carson
|
|
|
24
30
|
|
|
25
31
|
# --- What the warehouse knows ---
|
|
26
32
|
|
|
27
|
-
# The label on the current
|
|
33
|
+
# The label on the current workbench (branch name).
|
|
28
34
|
def current_label
|
|
29
35
|
git( "rev-parse", "--abbrev-ref", "HEAD" ).first.strip
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
# The tip of the parcel on the current
|
|
38
|
+
# The tip of the parcel on the current workbench (commit SHA).
|
|
33
39
|
def current_head
|
|
34
40
|
git( "rev-parse", "HEAD" ).first.strip
|
|
35
41
|
end
|
|
@@ -44,7 +50,7 @@ module Carson
|
|
|
44
50
|
@bureau_address
|
|
45
51
|
end
|
|
46
52
|
|
|
47
|
-
# Is the warehouse floor clean? No uncommitted changes on the current
|
|
53
|
+
# Is the warehouse floor clean? No uncommitted changes on the current workbench.
|
|
48
54
|
def clean?
|
|
49
55
|
output, _, status = git( "status", "--porcelain" )
|
|
50
56
|
status.success? && output.strip.empty?
|
|
@@ -78,7 +84,6 @@ module Carson
|
|
|
78
84
|
# Ensure the warehouse complies with company standards (template sync).
|
|
79
85
|
# Delegates to the injected compliance checker. If no checker is set,
|
|
80
86
|
# the warehouse assumes compliance — no templates to enforce.
|
|
81
|
-
# Returns a hash: { compliant: true/false, committed: true/false, error: nil/string }
|
|
82
87
|
def submit_compliance!
|
|
83
88
|
return { compliant: true, committed: false } unless @compliance_checker
|
|
84
89
|
|
|
@@ -87,15 +92,13 @@ module Carson
|
|
|
87
92
|
|
|
88
93
|
# Update the warehouse's production standard — rebase onto latest registry state.
|
|
89
94
|
# Called after the bureau refuses a parcel for being behind standard.
|
|
90
|
-
# Returns true on success, false on failure.
|
|
91
95
|
def rebase_on_latest_standard!( registry: "#{bureau_address}/#{main_label}" )
|
|
92
96
|
_, _, status = git( "rebase", registry )
|
|
93
97
|
status.success?
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
# Pack a parcel — stage all changes and commit.
|
|
97
|
-
# Refuses if the
|
|
98
|
-
# Returns true on success, false on failure.
|
|
101
|
+
# Refuses if the workbench is sealed (parcel already in flight).
|
|
99
102
|
def pack!( message: )
|
|
100
103
|
if sealed?
|
|
101
104
|
raise "Branch is locked — PR ##{sealed_tracking_number} in flight. " \
|
|
@@ -106,38 +109,8 @@ module Carson
|
|
|
106
109
|
status.success?
|
|
107
110
|
end
|
|
108
111
|
|
|
109
|
-
# --- Shelf seal ---
|
|
110
|
-
|
|
111
|
-
# Seal the shelf — no more packing until the delivery outcome is confirmed.
|
|
112
|
-
# The courier seals the shelf after shipping and filing the waybill.
|
|
113
|
-
# The marker lives outside the worktree (~/.carson/seals/) so it does
|
|
114
|
-
# not pollute git status or block delivery with a dirty-tree guard.
|
|
115
|
-
def seal_shelf!( tracking_number: )
|
|
116
|
-
marker = delivering_marker_path
|
|
117
|
-
FileUtils.mkdir_p( File.dirname( marker ) )
|
|
118
|
-
File.write( marker, "#{tracking_number}\n#{@path}" )
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Unseal the shelf — the courier brought back the parcel.
|
|
122
|
-
# Called when the delivery outcome is held or rejected.
|
|
123
|
-
def unseal_shelf!
|
|
124
|
-
File.delete( delivering_marker_path ) if File.exist?( delivering_marker_path )
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Is this shelf sealed for a delivery in flight?
|
|
128
|
-
def sealed?
|
|
129
|
-
File.exist?( delivering_marker_path )
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# The tracking number of the in-flight delivery (nil if not sealed).
|
|
133
|
-
def sealed_tracking_number
|
|
134
|
-
return nil unless sealed?
|
|
135
|
-
File.read( delivering_marker_path ).lines.first.strip
|
|
136
|
-
end
|
|
137
|
-
|
|
138
112
|
# Receive the latest standard from the registry after a parcel is accepted.
|
|
139
113
|
# Fast-forwards local main without switching branches.
|
|
140
|
-
# Returns true on success, false on failure.
|
|
141
114
|
#
|
|
142
115
|
# Two paths depending on the main worktree's checkout state:
|
|
143
116
|
# - Main checked out → merge --ff-only (updates ref + working tree).
|
|
@@ -146,24 +119,20 @@ module Carson
|
|
|
146
119
|
def receive_latest_standard!( remote: bureau_address )
|
|
147
120
|
root = main_worktree_root
|
|
148
121
|
|
|
149
|
-
# Fetch remote tracking refs — always safe, even when main is checked out.
|
|
150
122
|
_, _, fetch_status = Open3.capture3( "git", "-C", root, "fetch", remote )
|
|
151
123
|
return false unless fetch_status.success?
|
|
152
124
|
|
|
153
|
-
# Determine how to advance local main.
|
|
154
125
|
head_ref, _, head_status = Open3.capture3(
|
|
155
126
|
"git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"
|
|
156
127
|
)
|
|
157
128
|
return false unless head_status.success?
|
|
158
129
|
|
|
159
130
|
if head_ref.strip == @main_label
|
|
160
|
-
# Main is checked out in the main worktree — fast-forward via merge.
|
|
161
131
|
_, _, merge_status = Open3.capture3(
|
|
162
132
|
"git", "-C", root, "merge", "--ff-only", "#{remote}/#{@main_label}"
|
|
163
133
|
)
|
|
164
134
|
merge_status.success?
|
|
165
135
|
else
|
|
166
|
-
# Main is not checked out — safe to update the ref via fetch refspec.
|
|
167
136
|
_, _, refspec_status = Open3.capture3(
|
|
168
137
|
"git", "-C", root, "fetch", remote, "#{@main_label}:#{@main_label}"
|
|
169
138
|
)
|
|
@@ -171,72 +140,8 @@ module Carson
|
|
|
171
140
|
end
|
|
172
141
|
end
|
|
173
142
|
|
|
174
|
-
# --- Bureau interaction ---
|
|
175
|
-
# The warehouse owns the connection to the bureau (GitHub).
|
|
176
|
-
# It queries, files, and registers on behalf of the courier.
|
|
177
|
-
|
|
178
|
-
# Check the parcel's status at the bureau using the waybill.
|
|
179
|
-
# Calls gh pr view + gh pr checks. Records findings onto the waybill.
|
|
180
|
-
def check_parcel_at_bureau_with( waybill )
|
|
181
|
-
state = fetch_pr_state_for( waybill.tracking_number )
|
|
182
|
-
ci, ci_diagnostic = fetch_ci_state_for( waybill.tracking_number )
|
|
183
|
-
waybill.record( state: state, ci: ci, ci_diagnostic: ci_diagnostic )
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# File a waybill at the bureau for this parcel.
|
|
187
|
-
# Calls gh pr create. Returns a Waybill with tracking number, or nil on failure.
|
|
188
|
-
def file_waybill_for!( parcel, title: nil, body_file: nil )
|
|
189
|
-
filing_title = title || Waybill.default_title_for( parcel.label )
|
|
190
|
-
arguments = [ "pr", "create", "--title", filing_title, "--head", parcel.label ]
|
|
191
|
-
|
|
192
|
-
if body_file && File.exist?( body_file )
|
|
193
|
-
arguments.push( "--body-file", body_file )
|
|
194
|
-
else
|
|
195
|
-
arguments.push( "--body", "" )
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
stdout, _, status = gh( *arguments )
|
|
199
|
-
tracking_number = nil
|
|
200
|
-
url = nil
|
|
201
|
-
|
|
202
|
-
if status.success?
|
|
203
|
-
url = stdout.to_s.strip
|
|
204
|
-
tracking_number = url.split( "/" ).last.to_i
|
|
205
|
-
tracking_number = nil if tracking_number == 0
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# If create failed or returned no number, try to find an existing PR.
|
|
209
|
-
unless tracking_number
|
|
210
|
-
tracking_number, url = find_existing_waybill_for( parcel.label )
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
return nil unless tracking_number
|
|
214
|
-
|
|
215
|
-
Waybill.new( label: parcel.label, tracking_number: tracking_number, url: url )
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Register the parcel at the bureau using the waybill.
|
|
219
|
-
# Calls gh pr merge. Stamps the waybill on success.
|
|
220
|
-
def register_parcel_at_bureau_with!( waybill, method: )
|
|
221
|
-
_, _, status = gh( "pr", "merge", waybill.tracking_number.to_s, "--#{method}" )
|
|
222
|
-
if status.success?
|
|
223
|
-
waybill.stamp( :accepted )
|
|
224
|
-
else
|
|
225
|
-
# Re-check the state — the merge may have revealed a new blocker.
|
|
226
|
-
check_parcel_at_bureau_with( waybill )
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
|
|
230
143
|
# --- Inventory ---
|
|
231
144
|
|
|
232
|
-
# All shelves (worktree paths).
|
|
233
|
-
def shelves
|
|
234
|
-
output, = git( "worktree", "list", "--porcelain" )
|
|
235
|
-
output.lines
|
|
236
|
-
.select { it.start_with?( "worktree " ) }
|
|
237
|
-
.map { it.sub( "worktree ", "" ).strip }
|
|
238
|
-
end
|
|
239
|
-
|
|
240
145
|
# All labels (branch names).
|
|
241
146
|
def labels
|
|
242
147
|
output, = git( "branch", "--format", "%(refname:short)" )
|
|
@@ -249,88 +154,31 @@ module Carson
|
|
|
249
154
|
merged_output.lines.map { it.strip }.include?( name )
|
|
250
155
|
end
|
|
251
156
|
|
|
252
|
-
#
|
|
253
|
-
|
|
157
|
+
# All worktree paths (transitional — use workbenches for Worktree instances).
|
|
158
|
+
def shelves
|
|
159
|
+
output, = git( "worktree", "list", "--porcelain" )
|
|
160
|
+
output.lines
|
|
161
|
+
.select { it.start_with?( "worktree " ) }
|
|
162
|
+
.map { it.sub( "worktree ", "" ).strip }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The main warehouse location — resolves correctly even from a workbench.
|
|
254
166
|
def main_worktree_root
|
|
255
167
|
git_common_dir, = git( "rev-parse", "--path-format=absolute", "--git-common-dir" )
|
|
256
168
|
common = git_common_dir.strip
|
|
257
|
-
# If it ends with /.git, the parent is the main worktree root.
|
|
258
169
|
common.end_with?( "/.git" ) ? File.dirname( common ) : common
|
|
259
170
|
end
|
|
260
171
|
|
|
261
172
|
private
|
|
262
173
|
|
|
263
|
-
# Path to the delivery marker file — signals the shelf is sealed.
|
|
264
|
-
# Lives outside the worktree at ~/.carson/seals/<sha256-of-path>
|
|
265
|
-
# so it does not pollute git status.
|
|
266
|
-
def delivering_marker_path
|
|
267
|
-
seals_dir = File.join( Dir.home, ".carson", "seals" )
|
|
268
|
-
key = Digest::SHA256.hexdigest( @path )
|
|
269
|
-
File.join( seals_dir, key )
|
|
270
|
-
end
|
|
271
|
-
|
|
272
174
|
# All git commands go through this single gateway.
|
|
273
|
-
# Returns [stdout, stderr, status].
|
|
274
175
|
def git( *arguments )
|
|
275
176
|
Open3.capture3( "git", "-C", path, *arguments )
|
|
276
177
|
end
|
|
277
178
|
|
|
278
179
|
# All gh commands go through this single gateway.
|
|
279
|
-
# Returns [stdout, stderr, status].
|
|
280
180
|
def gh( *arguments )
|
|
281
181
|
Open3.capture3( "gh", *arguments, chdir: path )
|
|
282
182
|
end
|
|
283
|
-
|
|
284
|
-
# Fetch PR state from the bureau for a tracking number.
|
|
285
|
-
# Returns the parsed state hash, or nil on failure.
|
|
286
|
-
def fetch_pr_state_for( tracking_number )
|
|
287
|
-
stdout, _, status = gh(
|
|
288
|
-
"pr", "view", tracking_number.to_s,
|
|
289
|
-
"--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
|
|
290
|
-
)
|
|
291
|
-
return nil unless status.success?
|
|
292
|
-
|
|
293
|
-
JSON.parse( stdout )
|
|
294
|
-
rescue JSON::ParserError
|
|
295
|
-
nil
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# Fetch CI state from the bureau for a tracking number.
|
|
299
|
-
# Returns [ci_symbol, diagnostic_or_nil].
|
|
300
|
-
# Captures the first line of stderr as diagnostic when the command fails.
|
|
301
|
-
def fetch_ci_state_for( tracking_number )
|
|
302
|
-
stdout, stderr, status = gh(
|
|
303
|
-
"pr", "checks", tracking_number.to_s,
|
|
304
|
-
"--json", "name,bucket"
|
|
305
|
-
)
|
|
306
|
-
unless status.success?
|
|
307
|
-
return [ :error, stderr.to_s.strip.lines.first&.strip ]
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
checks = JSON.parse( stdout ) rescue []
|
|
311
|
-
return [ :none, nil ] if checks.empty?
|
|
312
|
-
|
|
313
|
-
buckets = checks.map { it[ "bucket" ].to_s.downcase }
|
|
314
|
-
return [ :fail, nil ] if buckets.include?( "fail" )
|
|
315
|
-
return [ :pending, nil ] if buckets.include?( "pending" )
|
|
316
|
-
|
|
317
|
-
[ :pass, nil ]
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
# Try to find an existing PR for this label at the bureau.
|
|
321
|
-
# Returns [tracking_number, url] or [nil, nil].
|
|
322
|
-
def find_existing_waybill_for( label )
|
|
323
|
-
stdout, _, status = gh(
|
|
324
|
-
"pr", "view", label,
|
|
325
|
-
"--json", "number,url,state"
|
|
326
|
-
)
|
|
327
|
-
if status.success?
|
|
328
|
-
data = JSON.parse( stdout ) rescue nil
|
|
329
|
-
if data && data[ "number" ] && data[ "state" ] == "OPEN"
|
|
330
|
-
return [ data[ "number" ], data[ "url" ].to_s ]
|
|
331
|
-
end
|
|
332
|
-
end
|
|
333
|
-
[ nil, nil ]
|
|
334
|
-
end
|
|
335
183
|
end
|
|
336
184
|
end
|
data/lib/carson/worktree.rb
CHANGED
|
@@ -420,6 +420,16 @@ module Carson
|
|
|
420
420
|
false
|
|
421
421
|
end
|
|
422
422
|
|
|
423
|
+
# Is the workbench surface clean? (no uncommitted changes)
|
|
424
|
+
def clean?
|
|
425
|
+
return false unless exists?
|
|
426
|
+
|
|
427
|
+
stdout, = Open3.capture3( "git", "status", "--porcelain", chdir: path )
|
|
428
|
+
stdout.to_s.strip.empty?
|
|
429
|
+
rescue StandardError
|
|
430
|
+
false
|
|
431
|
+
end
|
|
432
|
+
|
|
423
433
|
# rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
|
|
424
434
|
private
|
|
425
435
|
# rubocop:enable Layout/AccessModifierIndentation
|
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: 4.1.
|
|
4
|
+
version: 4.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -53,11 +53,11 @@ files:
|
|
|
53
53
|
- RELEASE.md
|
|
54
54
|
- VERSION
|
|
55
55
|
- carson.gemspec
|
|
56
|
-
- config
|
|
57
|
-
- config
|
|
58
|
-
- config
|
|
59
|
-
- config
|
|
60
|
-
- config
|
|
56
|
+
- config/hooks/command-guard
|
|
57
|
+
- config/hooks/pre-commit
|
|
58
|
+
- config/hooks/pre-merge-commit
|
|
59
|
+
- config/hooks/pre-push
|
|
60
|
+
- config/hooks/prepare-commit-msg
|
|
61
61
|
- exe/carson
|
|
62
62
|
- icon.svg
|
|
63
63
|
- lib/carson.rb
|
|
@@ -103,6 +103,9 @@ files:
|
|
|
103
103
|
- lib/carson/runtime/status.rb
|
|
104
104
|
- lib/carson/version.rb
|
|
105
105
|
- lib/carson/warehouse.rb
|
|
106
|
+
- lib/carson/warehouse/bureau.rb
|
|
107
|
+
- lib/carson/warehouse/seal.rb
|
|
108
|
+
- lib/carson/warehouse/workbench.rb
|
|
106
109
|
- lib/carson/waybill.rb
|
|
107
110
|
- lib/carson/worktree.rb
|
|
108
111
|
homepage: https://github.com/wanghailei/carson
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|