carson 3.22.0 → 3.22.1

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.
@@ -0,0 +1,497 @@
1
+ # Domain object representing a single git worktree entry.
2
+ # Owns its path, branch, and operating context (runtime).
3
+ # Answers state queries (CWD containment, process holds) and
4
+ # owns the full lifecycle: create, remove, list, sweep.
5
+ # Runtime provides infrastructure — git, config, output —
6
+ # the way ActiveRecord models hold a database connection.
7
+ require "fileutils"
8
+ require "json"
9
+ require "open3"
10
+
11
+ module Carson
12
+ class Worktree
13
+ # Agent directory names whose worktrees Carson may sweep.
14
+ AGENT_DIRS = %w[ .claude .codex ].freeze
15
+
16
+ attr_reader :path, :branch
17
+
18
+ def initialize( path:, branch:, runtime: nil )
19
+ @path = path
20
+ @branch = branch
21
+ @runtime = runtime
22
+ end
23
+
24
+ # --- Class lifecycle methods ---
25
+
26
+ # Parses `git worktree list --porcelain` into Worktree instances.
27
+ # Normalises paths with realpath so comparisons work across symlink differences.
28
+ def self.list( runtime: )
29
+ raw = runtime.git_capture!( "worktree", "list", "--porcelain" )
30
+ entries = []
31
+ current_path = nil
32
+ current_branch = :unset
33
+ raw.lines.each do |line|
34
+ line = line.strip
35
+ if line.empty?
36
+ entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
37
+ current_path = nil
38
+ current_branch = :unset
39
+ elsif line.start_with?( "worktree " )
40
+ current_path = runtime.realpath_safe( line.sub( "worktree ", "" ) )
41
+ elsif line.start_with?( "branch " )
42
+ current_branch = line.sub( "branch refs/heads/", "" )
43
+ elsif line == "detached"
44
+ current_branch = nil
45
+ end
46
+ end
47
+ entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
48
+ entries
49
+ end
50
+
51
+ # Finds the Worktree entry for a given path, or nil.
52
+ # Compares using realpath to handle symlink differences.
53
+ def self.find( path:, runtime: )
54
+ canonical = runtime.realpath_safe( path )
55
+ list( runtime: runtime ).find { it.path == canonical }
56
+ end
57
+
58
+ # Returns true if the path is a registered git worktree.
59
+ def self.registered?( path:, runtime: )
60
+ canonical = runtime.realpath_safe( path )
61
+ list( runtime: runtime ).any? { it.path == canonical }
62
+ end
63
+
64
+ # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
65
+ # Uses main_worktree_root so this works even when called from inside a worktree.
66
+ def self.create!( name:, runtime:, json_output: false )
67
+ worktrees_dir = File.join( runtime.main_worktree_root, ".claude", "worktrees" )
68
+ worktree_path = File.join( worktrees_dir, name )
69
+
70
+ if Dir.exist?( worktree_path )
71
+ return finish(
72
+ result: { command: "worktree create", status: "error", name: name, path: worktree_path,
73
+ error: "worktree already exists: #{name}",
74
+ recovery: "carson worktree remove #{name}, then retry" },
75
+ exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
76
+ )
77
+ end
78
+
79
+ # Determine the base branch (main branch from config).
80
+ base = runtime.config.main_branch
81
+
82
+ # Sync main from remote before branching so the worktree starts
83
+ # from the latest code. Prevents stale-base merge conflicts later.
84
+ # Best-effort — if pull fails (non-ff, offline), continue anyway.
85
+ main_root = runtime.main_worktree_root
86
+ _, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", runtime.config.git_remote, base )
87
+ runtime.puts_verbose pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}"
88
+
89
+ # Ensure .claude/ is excluded from git status in the host repository.
90
+ # Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
91
+ ensure_claude_dir_excluded!( runtime: runtime )
92
+
93
+ # Create the worktree with a new branch based on the main branch.
94
+ FileUtils.mkdir_p( worktrees_dir )
95
+ _, worktree_stderr, worktree_success, = runtime.git_run( "worktree", "add", worktree_path, "-b", name, base )
96
+ unless worktree_success
97
+ error_text = worktree_stderr.to_s.strip
98
+ error_text = "unable to create worktree" if error_text.empty?
99
+ return finish(
100
+ result: { command: "worktree create", status: "error", name: name,
101
+ error: error_text },
102
+ exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
103
+ )
104
+ end
105
+
106
+ finish(
107
+ result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
108
+ exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
109
+ )
110
+ end
111
+
112
+ # Removes a worktree: directory, git registration, and branch.
113
+ # Never forces removal — if the worktree has uncommitted changes, refuses unless
114
+ # the caller explicitly passes force: true via CLI --force flag.
115
+ def self.remove!( path:, runtime:, force: false, json_output: false )
116
+ fingerprint_status = runtime.block_if_outsider_fingerprints!
117
+ unless fingerprint_status.nil?
118
+ if json_output
119
+ runtime.output.puts JSON.pretty_generate( {
120
+ command: "worktree remove", status: "block",
121
+ error: "Carson-owned artefacts detected in host repository",
122
+ recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
123
+ exit_code: Runtime::EXIT_BLOCK
124
+ } )
125
+ end
126
+ return fingerprint_status
127
+ end
128
+
129
+ resolved_path = resolve_path( path: path, runtime: runtime )
130
+
131
+ # Missing directory: worktree was destroyed externally (e.g. gh pr merge
132
+ # --delete-branch). Clean up the stale git registration and delete the branch.
133
+ if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
134
+ return remove_missing!( resolved_path: resolved_path, runtime: runtime, json_output: json_output )
135
+ end
136
+
137
+ unless registered?( path: resolved_path, runtime: runtime )
138
+ return finish(
139
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
140
+ error: "#{resolved_path} is not a registered worktree",
141
+ recovery: "git worktree list" },
142
+ exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
143
+ )
144
+ end
145
+
146
+ # Safety: refuse if the caller's shell CWD is inside the worktree.
147
+ # Removing a directory while a shell is inside it kills the shell permanently.
148
+ entry = find( path: resolved_path, runtime: runtime )
149
+ if entry&.holds_cwd?
150
+ safe_root = runtime.main_worktree_root
151
+ return finish(
152
+ result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
153
+ error: "current working directory is inside this worktree",
154
+ recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}" },
155
+ exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
156
+ )
157
+ end
158
+
159
+ # Safety: refuse if another process has its CWD inside the worktree.
160
+ # Protects against cross-process CWD crashes (e.g. an agent session
161
+ # removed by a separate cleanup process while the agent's shell is inside).
162
+ if entry&.held_by_other_process?
163
+ return finish(
164
+ result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
165
+ error: "another process has its working directory inside this worktree",
166
+ recovery: "wait for the other session to finish, then retry" },
167
+ exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
168
+ )
169
+ end
170
+
171
+ branch = entry&.branch
172
+ runtime.puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
173
+
174
+ # Safety: refuse if the branch has unpushed commits (unless --force).
175
+ # Prevents accidental destruction of work that exists only locally.
176
+ unless force
177
+ unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path, runtime: runtime )
178
+ if unpushed
179
+ return finish(
180
+ result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
181
+ branch: branch,
182
+ error: unpushed[ :error ],
183
+ recovery: unpushed[ :recovery ] },
184
+ exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
185
+ )
186
+ end
187
+ end
188
+
189
+ # Step 1: remove the worktree (directory + git registration).
190
+ rm_args = [ "worktree", "remove" ]
191
+ rm_args << "--force" if force
192
+ rm_args << resolved_path
193
+ _, rm_stderr, rm_success, = runtime.git_run( *rm_args )
194
+ unless rm_success
195
+ error_text = rm_stderr.to_s.strip
196
+ error_text = "unable to remove worktree" if error_text.empty?
197
+ if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
198
+ return finish(
199
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
200
+ error: "worktree has uncommitted changes",
201
+ recovery: "commit or discard changes first, or use --force to override" },
202
+ exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
203
+ )
204
+ end
205
+ return finish(
206
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
207
+ error: error_text },
208
+ exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
209
+ )
210
+ end
211
+ runtime.puts_verbose "worktree_removed: #{resolved_path}"
212
+
213
+ # Step 2: delete the local branch.
214
+ branch_deleted = false
215
+ if branch && !runtime.config.protected_branches.include?( branch )
216
+ _, del_stderr, del_success, = runtime.git_run( "branch", "-D", branch )
217
+ if del_success
218
+ runtime.puts_verbose "branch_deleted: #{branch}"
219
+ branch_deleted = true
220
+ else
221
+ runtime.puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
222
+ end
223
+ end
224
+
225
+ # Step 3: delete the remote branch (best-effort).
226
+ remote_deleted = false
227
+ if branch && !runtime.config.protected_branches.include?( branch )
228
+ remote_branch = branch
229
+ _, _, rd_success, = runtime.git_run( "push", runtime.config.git_remote, "--delete", remote_branch )
230
+ if rd_success
231
+ runtime.puts_verbose "remote_branch_deleted: #{runtime.config.git_remote}/#{remote_branch}"
232
+ remote_deleted = true
233
+ end
234
+ end
235
+ finish(
236
+ result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
237
+ branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
238
+ exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
239
+ )
240
+ end
241
+
242
+ # Removes agent-owned worktrees whose branch content is already on main.
243
+ # Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
244
+ # under the main repo root. Safe: skips detached HEADs, the caller's CWD,
245
+ # and dirty working trees (git worktree remove refuses without --force).
246
+ def self.sweep_stale!( runtime: )
247
+ main_root = runtime.main_worktree_root
248
+ worktrees = list( runtime: runtime )
249
+
250
+ agent_prefixes = AGENT_DIRS.filter_map do |dir|
251
+ full = File.join( main_root, dir, "worktrees" )
252
+ File.join( runtime.realpath_safe( full ), "" ) if Dir.exist?( full )
253
+ end
254
+ return if agent_prefixes.empty?
255
+
256
+ worktrees.each do |worktree|
257
+ next unless worktree.branch
258
+ next unless agent_prefixes.any? { |prefix| worktree.path.start_with?( prefix ) }
259
+ next if worktree.holds_cwd?
260
+ next if worktree.held_by_other_process?
261
+ next unless runtime.branch_absorbed_into_main?( branch: worktree.branch )
262
+
263
+ # Remove the worktree (no --force: refuses if dirty working tree).
264
+ _, _, rm_success, = runtime.git_run( "worktree", "remove", worktree.path )
265
+ next unless rm_success
266
+
267
+ runtime.puts_verbose "swept stale worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch})"
268
+
269
+ # Delete the local branch now that no worktree holds it.
270
+ if !runtime.config.protected_branches.include?( worktree.branch )
271
+ runtime.git_run( "branch", "-D", worktree.branch )
272
+ runtime.puts_verbose "deleted branch: #{worktree.branch}"
273
+ end
274
+ end
275
+ end
276
+
277
+ # --- Instance query methods ---
278
+
279
+ # Is the current process CWD inside this worktree?
280
+ def holds_cwd?
281
+ cwd = realpath_safe( Dir.pwd )
282
+ worktree = realpath_safe( path )
283
+ normalised = File.join( worktree, "" )
284
+ cwd == worktree || cwd.start_with?( normalised )
285
+ rescue StandardError
286
+ false
287
+ end
288
+
289
+ # Does another process have its CWD inside this worktree?
290
+ def held_by_other_process?
291
+ canonical = realpath_safe( path )
292
+ return false if canonical.nil? || canonical.empty?
293
+ return false unless Dir.exist?( canonical )
294
+
295
+ stdout, = Open3.capture3( "lsof", "-d", "cwd" )
296
+ # Do NOT gate on exit status — lsof exits non-zero on macOS when SIP blocks
297
+ # access to some system processes, even though user-process output is valid.
298
+ return false if stdout.nil? || stdout.empty?
299
+
300
+ normalised = File.join( canonical, "" )
301
+ my_pid = Process.pid
302
+ stdout.lines.drop( 1 ).any? do |line|
303
+ fields = line.strip.split( /\s+/ )
304
+ next false unless fields.length >= 9
305
+ next false if fields[ 1 ].to_i == my_pid
306
+ name = fields[ 8.. ].join( " " )
307
+ name == canonical || name.start_with?( normalised )
308
+ end
309
+ rescue Errno::ENOENT
310
+ # lsof not installed.
311
+ false
312
+ rescue StandardError
313
+ false
314
+ end
315
+
316
+ # rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
317
+ private
318
+ # rubocop:enable Layout/AccessModifierIndentation
319
+
320
+ attr_reader :runtime
321
+
322
+ # --- Private class methods ---
323
+
324
+ # Handles removal when the worktree directory is already gone (destroyed
325
+ # externally by gh pr merge --delete-branch or manual deletion).
326
+ # Prunes the stale git worktree entry and cleans up the branch.
327
+ def self.remove_missing!( resolved_path:, runtime:, json_output: )
328
+ branch = find( path: resolved_path, runtime: runtime )&.branch
329
+ runtime.puts_verbose "worktree_remove_missing: path=#{resolved_path} branch=#{branch}"
330
+
331
+ # Prune the stale worktree entry from git's registry.
332
+ runtime.git_run( "worktree", "prune" )
333
+ runtime.puts_verbose "pruned stale worktree entry: #{resolved_path}"
334
+
335
+ # Delete the local branch.
336
+ branch_deleted = false
337
+ if branch && !runtime.config.protected_branches.include?( branch )
338
+ _, _, del_success, = runtime.git_run( "branch", "-D", branch )
339
+ if del_success
340
+ runtime.puts_verbose "branch_deleted: #{branch}"
341
+ branch_deleted = true
342
+ end
343
+ end
344
+
345
+ # Delete the remote branch (best-effort).
346
+ remote_deleted = false
347
+ if branch && !runtime.config.protected_branches.include?( branch )
348
+ _, _, rd_success, = runtime.git_run( "push", runtime.config.git_remote, "--delete", branch )
349
+ if rd_success
350
+ runtime.puts_verbose "remote_branch_deleted: #{runtime.config.git_remote}/#{branch}"
351
+ remote_deleted = true
352
+ end
353
+ end
354
+
355
+ finish(
356
+ result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
357
+ branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
358
+ exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
359
+ )
360
+ end
361
+ private_class_method :remove_missing!
362
+
363
+ # Unified output for worktree results — JSON or human-readable.
364
+ def self.finish( result:, exit_code:, runtime:, json_output: )
365
+ result[ :exit_code ] = exit_code
366
+
367
+ if json_output
368
+ runtime.output.puts JSON.pretty_generate( result )
369
+ else
370
+ print_human( result: result, runtime: runtime )
371
+ end
372
+
373
+ exit_code
374
+ end
375
+ private_class_method :finish
376
+
377
+ # Human-readable output for worktree results.
378
+ def self.print_human( result:, runtime: )
379
+ command = result[ :command ]
380
+ status = result[ :status ]
381
+
382
+ case status
383
+ when "ok"
384
+ case command
385
+ when "worktree create"
386
+ runtime.puts_line "Worktree created: #{result[ :name ]}"
387
+ runtime.puts_line " Path: #{result[ :path ]}"
388
+ runtime.puts_line " Branch: #{result[ :branch ]}"
389
+ when "worktree remove"
390
+ unless runtime.verbose?
391
+ runtime.puts_line "Worktree removed: #{result[ :name ]}"
392
+ end
393
+ end
394
+ when "error"
395
+ runtime.puts_line result[ :error ]
396
+ runtime.puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
397
+ when "block"
398
+ runtime.puts_line "#{result[ :error ]&.capitalize || 'Held'}: #{result[ :name ]}"
399
+ runtime.puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
400
+ end
401
+ end
402
+ private_class_method :print_human
403
+
404
+ # Checks whether a branch has unpushed commits that would be lost on removal.
405
+ # Content-aware: after squash/rebase merge, SHAs differ but tree content may match main.
406
+ # Compares content, not SHAs.
407
+ # Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
408
+ def self.check_unpushed_commits( branch:, worktree_path:, runtime: )
409
+ return nil unless branch
410
+
411
+ remote = runtime.config.git_remote
412
+ remote_ref = "#{remote}/#{branch}"
413
+ ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: worktree_path )
414
+ if !ahead_status.success?
415
+ # Remote ref does not exist. Only block if the branch has unique commits vs main.
416
+ unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{runtime.config.main_branch}..#{branch}", chdir: worktree_path )
417
+ if unique_status.success? && unique.strip.to_i > 0
418
+ # Content-aware check: after squash/rebase merge, commit SHAs differ
419
+ # but the tree content may be identical to main. Compare content,
420
+ # not SHAs — if the diff is empty, the work is already on main.
421
+ _, _, diff_ok, = Open3.capture3( "git", "diff", "--quiet", runtime.config.main_branch, branch, chdir: worktree_path )
422
+ unless diff_ok.success?
423
+ return { error: "branch has not been pushed to #{remote}",
424
+ recovery: "git -C #{worktree_path} push -u #{remote} #{branch}, or use --force to override" }
425
+ end
426
+ # Diff is empty — content is on main (squash/rebase merged). Safe.
427
+ runtime.puts_verbose "branch #{branch} content matches main — squash/rebase merged, safe to remove"
428
+ end
429
+ elsif ahead.strip.to_i > 0
430
+ return { error: "worktree has unpushed commits",
431
+ recovery: "git -C #{worktree_path} push #{remote} #{branch}, or use --force to override" }
432
+ end
433
+
434
+ nil
435
+ end
436
+ private_class_method :check_unpushed_commits
437
+
438
+ # Resolves a worktree path: if it's a bare name, first tries the flat
439
+ # .claude/worktrees/<name> convention; if that isn't registered, searches
440
+ # all registered worktrees for one whose directory name matches.
441
+ # This handles worktrees created by external tools (e.g. Claude Code) that
442
+ # nest under a subdirectory like .claude/worktrees/claude/<name>.
443
+ # Returns the canonical (realpath) form so comparisons against git worktree list
444
+ # succeed, even when the OS resolves symlinks differently.
445
+ # Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
446
+ def self.resolve_path( path:, runtime: )
447
+ if path.include?( "/" )
448
+ return runtime.realpath_safe( path )
449
+ end
450
+
451
+ root = runtime.main_worktree_root
452
+ candidate = File.join( root, ".claude", "worktrees", path )
453
+ canonical = runtime.realpath_safe( candidate )
454
+ return canonical if registered?( path: canonical, runtime: runtime )
455
+
456
+ # Bare name didn't match flat layout — search registered worktrees by dirname.
457
+ matches = list( runtime: runtime ).select { File.basename( it.path ) == path }
458
+ return matches.first.path if matches.size == 1
459
+
460
+ # No match or ambiguous — return the flat candidate and let the caller
461
+ # produce the appropriate error message.
462
+ canonical
463
+ end
464
+ private_class_method :resolve_path
465
+
466
+ # Adds .claude/ to .git/info/exclude if not already present.
467
+ # This prevents worktree directories from appearing as untracked files
468
+ # in the host repository. Uses the local exclude file (never committed)
469
+ # so the host repo's .gitignore is never touched.
470
+ # Uses main_worktree_root — worktrees have .git as a file, not a directory.
471
+ def self.ensure_claude_dir_excluded!( runtime: )
472
+ git_dir = File.join( runtime.main_worktree_root, ".git" )
473
+ return unless File.directory?( git_dir )
474
+
475
+ info_dir = File.join( git_dir, "info" )
476
+ exclude_path = File.join( info_dir, "exclude" )
477
+
478
+ FileUtils.mkdir_p( info_dir )
479
+ existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
480
+ return if existing.lines.any? { |line| line.strip == ".claude/" }
481
+
482
+ File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
483
+ rescue StandardError
484
+ # Best-effort — do not block worktree creation if exclude fails.
485
+ end
486
+ private_class_method :ensure_claude_dir_excluded!
487
+
488
+ # Instance-level realpath helper for query methods.
489
+ def realpath_safe( a_path )
490
+ return runtime.realpath_safe( a_path ) if runtime
491
+
492
+ File.realpath( a_path )
493
+ rescue Errno::ENOENT
494
+ File.expand_path( a_path )
495
+ end
496
+ end
497
+ end
data/lib/carson.rb CHANGED
@@ -5,6 +5,7 @@ module Carson
5
5
  BADGE = "\u29D3".freeze # ⧓ BLACK BOWTIE (U+29D3)
6
6
  end
7
7
 
8
+ require_relative "carson/worktree"
8
9
  require_relative "carson/config"
9
10
  require_relative "carson/adapters/git"
10
11
  require_relative "carson/adapters/github"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.22.0
4
+ version: 3.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -11,12 +11,13 @@ bindir: exe
11
11
  cert_chain: []
12
12
  date: 1980-01-02 00:00:00.000000000 Z
13
13
  dependencies: []
14
- description: 'Carson is a governance runtime that lives outside the repositories it
15
- governs — no Carson-owned artefacts in your repo. On every commit, managed hooks
16
- enforce centralised lint policy and review gates. At portfolio level, carson govern
17
- triages every open PR across your registered repositories: merge what''s ready,
18
- dispatch coding agents to fix what''s failing, escalate what needs human judgement.
19
- One command, all your projects, unmanned.'
14
+ description: 'Carson is an autonomous git strategist and repositories governor that
15
+ lives outside the repositories it governs — no Carson-owned artefacts in your repo.
16
+ As strategist, Carson knows when to branch, how to isolate concurrent work, and
17
+ how to recover from failures. As governor, it enforces review gates, manages templates,
18
+ and triages every open PR across your portfolio: merge what''s ready, dispatch coding
19
+ agents to fix what''s failing, escalate what needs human judgement. One command,
20
+ all your projects, unmanned.'
20
21
  email:
21
22
  - wanghailei@users.noreply.github.com
22
23
  executables:
@@ -24,8 +25,6 @@ executables:
24
25
  extensions: []
25
26
  extra_rdoc_files: []
26
27
  files:
27
- - ".github/copilot-instructions.md"
28
- - ".github/pull_request_template.md"
29
28
  - ".github/workflows/carson_policy.yml"
30
29
  - API.md
31
30
  - LICENSE
@@ -73,11 +72,7 @@ files:
73
72
  - lib/carson/runtime/setup.rb
74
73
  - lib/carson/runtime/status.rb
75
74
  - lib/carson/version.rb
76
- - templates/.github/AGENTS.md
77
- - templates/.github/CLAUDE.md
78
- - templates/.github/carson.md
79
- - templates/.github/copilot-instructions.md
80
- - templates/.github/pull_request_template.md
75
+ - lib/carson/worktree.rb
81
76
  homepage: https://github.com/wanghailei/carson
82
77
  licenses:
83
78
  - PolyForm-Shield-1.0.0
@@ -106,6 +101,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
106
101
  requirements: []
107
102
  rubygems_version: 4.0.3
108
103
  specification_version: 4
109
- summary: Autonomous repository governance — you write the code, Carson manages everything
110
- else.
104
+ summary: Autonomous git strategist and repositories governor — you write the code,
105
+ Carson manages everything else.
111
106
  test_files: []
@@ -1 +0,0 @@
1
- Read `.github/AGENTS.md` for repository governance rules enforced by Carson.
@@ -1,12 +0,0 @@
1
- ## Shared Scope and Validation
2
-
3
- - [ ] `single_business_intent`: this PR is one coherent domain or feature intent.
4
- - [ ] `carson audit` before commit.
5
- - [ ] `carson audit` before push.
6
- - [ ] `gh pr list --state open --limit 50` checked at session start (capture competing active PRs).
7
- - [ ] `gh pr list --state open --limit 50` re-checked immediately before merge decision.
8
- - [ ] Required CI checks are passing.
9
- - [ ] At least 60 seconds passed since the last push to allow AI reviewers to post.
10
- - [ ] No unresolved required conversation threads at merge time.
11
- - [ ] `carson review gate` passes with converged snapshots.
12
- - [ ] Every actionable top-level review item has a `Disposition:` disposition (`accepted`, `rejected`, `deferred`) with the target review URL.
@@ -1 +0,0 @@
1
- Read `.github/carson.md` for repository governance rules enforced by Carson.
@@ -1 +0,0 @@
1
- Read `.github/AGENTS.md` for repository governance rules enforced by Carson.
@@ -1,47 +0,0 @@
1
- # Carson Governance
2
-
3
- This repository is governed by [Carson](https://github.com/wanghailei/carson), an autonomous governance runtime. Carson lives on the maintainer's workstation, not inside this repository.
4
-
5
- ## Commands
6
-
7
- **Delivery:**
8
- ```bash
9
- carson deliver # push branch, create PR
10
- carson deliver --merge # push, create PR, merge if CI green and review clear
11
- ```
12
-
13
- **Before committing:**
14
- ```bash
15
- carson audit # full governance check — run before every commit
16
- carson template check # detect drift in .github/* managed files, including stale superseded files
17
- carson template apply # fix drift and remove superseded files
18
- ```
19
-
20
- **Before recommending merge:**
21
- ```bash
22
- carson review gate # block until actionable review findings are resolved
23
- ```
24
-
25
- **Branch housekeeping:**
26
- ```bash
27
- carson sync # fast-forward local main from remote
28
- carson prune # remove stale branches (safer than git branch -d on squash repos)
29
- carson housekeep # sync + prune + sweep stale worktrees
30
- ```
31
-
32
- ## Exit Codes
33
-
34
- - `0` — success
35
- - `1` — runtime or configuration error
36
- - `2` — policy blocked (hard stop; treat as expected failure in CI)
37
-
38
- ## Governance Rules
39
-
40
- - Before commit and before push, run `carson audit`.
41
- - At session start and again immediately before merge recommendation, run `gh pr list --state open --limit 50` and re-confirm active PR priorities.
42
- - Before merge recommendation, run `carson review gate`; it enforces warm-up wait, unresolved-thread convergence, and `Disposition:` acknowledgements for actionable top-level findings.
43
- - Actionable findings are unresolved review threads, any non-author `CHANGES_REQUESTED` review, or non-author comments/reviews with risk keywords (`bug`, `security`, `incorrect`, `block`, `fail`, `regression`).
44
- - `Disposition:` responses must include one token (`accepted`, `rejected`, `deferred`) and the target review URL.
45
- - Scheduled governance runs `carson review sweep` every 8 hours to track late actionable review activity.
46
- - Do not treat green checks or `mergeStateStatus: CLEAN` as sufficient if unresolved review threads remain.
47
- - Never suggest destructive operations on protected refs (`main`/`master`, local or remote).
@@ -1 +0,0 @@
1
- Read `.github/AGENTS.md` for repository governance rules enforced by Carson.
@@ -1,12 +0,0 @@
1
- ## Shared Scope and Validation
2
-
3
- - [ ] `single_business_intent`: this PR is one coherent domain or feature intent.
4
- - [ ] `carson audit` before commit.
5
- - [ ] `carson audit` before push.
6
- - [ ] `gh pr list --state open --limit 50` checked at session start (capture competing active PRs).
7
- - [ ] `gh pr list --state open --limit 50` re-checked immediately before merge decision.
8
- - [ ] Required CI checks are passing.
9
- - [ ] At least 60 seconds passed since the last push to allow AI reviewers to post.
10
- - [ ] No unresolved required conversation threads at merge time.
11
- - [ ] `carson review gate` passes with converged snapshots.
12
- - [ ] Every actionable top-level review item has a `Disposition:` disposition (`accepted`, `rejected`, `deferred`) with the target review URL.