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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d709f0737b7411687d882b100598ae5d86bef2cf05cc39b493e567c03e687aa4
4
- data.tar.gz: 968d6124cc0406f21f44ce6430e4635954f8aecb77bca53ff7d1d111cce2dbe3
3
+ metadata.gz: c882d441c7ea190db45ec65d5aad771de7a601c19375204dfabaa6ec285a6ebf
4
+ data.tar.gz: 6b7cc35fa68c90a5d493409f8bbc7799dd1cc2adacb2af0ff15cf42393d66c9c
5
5
  SHA512:
6
- metadata.gz: fae3226c3f11a5fb7a7269d086a8c1f1dfc2bbd5e4ca71b2823663891189fb1bc8a8da3eb67dd93a6462f20857f3ead10c4cd456efd618eb830b650b75b92f44
7
- data.tar.gz: 67bf606c7221408e585145f2e37452e260ed417de8016c7f0f776b70218db821cd4c9ba7ef564cf2d689336fe989d7a2bcceb22b0be492d3f6a4a649b85a8906
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
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", ".github", "hooks", "command-guard" )
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 = Worktree.remove_check( path: worktree.path, runtime: self, force: false, skip_unpushed: true )
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.fetch( :recovery )
147
- if check.fetch( :error ) == "worktree has uncommitted changes"
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: check.fetch( :exit_code ),
153
- error: check.fetch( :error ),
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", ".github", "hooks", hook_name )
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 live on Carson::Worktree; this module delegates
3
- # and keeps only methods that genuinely belong on Runtime (path resolution,
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 Carson::Worktree ---
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
- Worktree.create!( name: name, runtime: self, json_output: json_output )
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
- Worktree.remove!( path: worktree_path, runtime: self, force: force, skip_unpushed: skip_unpushed, json_output: json_output )
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
- Worktree.list( runtime: self )
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/.github/ ].freeze
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/.github/, then rerun carson recover --check #{check_name.inspect}"
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
@@ -1,18 +1,24 @@
1
1
  # A governed repository. In the FedEx metaphor, the warehouse is where
2
- # parcels (committed changes) are stored on shelves (worktrees) with
3
- # labels (branches). Git and gh commands are hidden inside — callers
4
- # never see git or GitHub terms.
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 stored on
12
- # shelves (worktrees) with labels (branches). Wraps git operations
13
- # with story-language methods. An intelligent warehouse that manages
14
- # itself: packing parcels, checking compliance, and sweeping up.
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 shelf (branch name).
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 shelf (commit SHA).
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 shelf.
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 shelf is sealed (parcel already in flight).
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
- # The main warehouse locationresolves correctly even from a side shelf.
253
- # Used by sync! and ledger recording to always reference the canonical path.
157
+ # All worktree paths (transitionaluse 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
@@ -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.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/.github/hooks/command-guard
57
- - config/.github/hooks/pre-commit
58
- - config/.github/hooks/pre-merge-commit
59
- - config/.github/hooks/pre-push
60
- - config/.github/hooks/prepare-commit-msg
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