claude-worktree 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f961f02caebcef789ed77e043a5390b49c50397e87d21588e74e0c17417a6da
4
- data.tar.gz: 91657ce429ea01a43424da72d87f71f9964a8da7e419ebb07fe59c30269c3a6d
3
+ metadata.gz: 1c13372671d9041cb88fbe74d783caec8d15cba40c9c0395b6ab64ef6105913c
4
+ data.tar.gz: dcf190dcc57792301fdddaf01ac7e880b56a14c97ee67f2095283ddc459b53dd
5
5
  SHA512:
6
- metadata.gz: a0b74a2251dc6c8ff2101179ce96d87045a86198065a86a90f8c14e6da2f86a75377824a6388c68cd20ecdb2330c25dc962d35557cce8c66b318ea329a9a0aaa
7
- data.tar.gz: eb1e06bd91998c652e2694a42c3ae664fd0052c90a0587a84f8fe1fc636d232dcbef6b91d55a04fd2d2efff6c5ade71cff4851479d304388c8303b918770a89e
6
+ metadata.gz: c1c74c92f54d58d1799bb99fa42526899dccf851c191d3db5ac193f03b49a93438f80866c28a37cd52243722863445d8a0619f81b834d376df434c3e48b722e2
7
+ data.tar.gz: 3c0e53ec321f5dd06aa0d79caec3f2bca13a7647e338f30c354a1a287c2712b4f83ef161f4ce37835b5b3196d547707ee68e7d5cbfeb3da10d35391a97b33036
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.4] - 2026-01-30
4
+
5
+ ### Added
6
+ - **Permanent CD on exit**: After quitting cwt, your shell stays in the last resumed worktree directory
7
+ - **Visible setup output**: `.cwt/setup` script now runs with visible output on first resume (not during worktree creation)
8
+ - **Teardown support**: Optional `.cwt/teardown` script runs before worktree deletion
9
+ - **CWT_ROOT environment variable**: Setup and teardown scripts receive `$CWT_ROOT` pointing to the repo root
10
+ - Integration tests for setup/teardown functionality
11
+ - Homebrew update instructions in deploy script
12
+
13
+ ### Changed
14
+ - Setup now runs on first resume instead of during worktree creation
15
+ - Setup only runs once per worktree (tracked via `.cwt_needs_setup` marker)
16
+
3
17
  ## [0.1.0] - 2026-01-29
4
18
 
5
19
  - Initial release
data/README.md CHANGED
@@ -32,7 +32,7 @@ gem install claude-worktree
32
32
 
33
33
  Or via Homebrew Tap:
34
34
  ```bash
35
- brew tap bengarcia/tap
35
+ brew tap benngarcia/tap
36
36
  brew install cwt
37
37
  ```
38
38
 
@@ -57,8 +57,10 @@ If this file exists, `cwt` will **skip the default symlinks** and execute your s
57
57
 
58
58
  ```bash
59
59
  #!/bin/bash
60
+ # $CWT_ROOT points to your repo root
61
+
60
62
  # Copy .env so we can modify it safely in this session
61
- cp ../.env .
63
+ cp "$CWT_ROOT/.env" .
62
64
 
63
65
  # Install dependencies freshly (cleaner than symlinking)
64
66
  npm ci
@@ -88,7 +90,7 @@ Run `cwt` in the root of any Git repository.
88
90
 
89
91
  ## 🤝 Contributing
90
92
 
91
- Bug reports and pull requests are welcome on GitHub at https://github.com/bengarcia/claude-worktree.
93
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bucket-robotics/claude-worktree.
92
94
 
93
95
  ## License
94
96
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Claude
4
4
  module Worktree
5
- VERSION = "0.1.3"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/cwt/app.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "ratatui_ruby"
4
4
  require "thread"
5
+ require_relative "repository"
6
+ require_relative "worktree"
5
7
  require_relative "model"
6
8
  require_relative "view"
7
9
  require_relative "update"
@@ -12,26 +14,35 @@ module Cwt
12
14
  POOL_SIZE = 4
13
15
 
14
16
  def self.run
15
- model = Model.new
16
-
17
+ # Discover repository from current directory (works from worktrees too)
18
+ repository = Repository.discover
19
+ unless repository
20
+ puts "Error: Not in a git repository"
21
+ exit 1
22
+ end
23
+
24
+ # Change to repo root for consistent paths
25
+ Dir.chdir(repository.root)
26
+
27
+ model = Model.new(repository)
28
+
17
29
  # Initialize Thread Pool
18
30
  @worker_queue = Queue.new
19
31
  @workers = POOL_SIZE.times.map do
20
32
  Thread.new do
21
- while task = @worker_queue.pop
22
- # Process task
33
+ while (task = @worker_queue.pop)
23
34
  begin
24
35
  case task[:type]
25
36
  when :fetch_status
26
37
  status = Git.get_status(task[:path])
27
- task[:result_queue] << {
28
- type: :update_status,
29
- path: task[:path],
30
- status: status,
31
- generation: task[:generation]
38
+ task[:result_queue] << {
39
+ type: :update_status,
40
+ path: task[:path],
41
+ status: status,
42
+ generation: task[:generation]
32
43
  }
33
44
  end
34
- rescue => e
45
+ rescue StandardError
35
46
  # Ignore worker errors
36
47
  end
37
48
  end
@@ -40,7 +51,7 @@ module Cwt
40
51
 
41
52
  # Initial Load
42
53
  Update.refresh_list(model)
43
-
54
+
44
55
  # Main Event Queue
45
56
  main_queue = Queue.new
46
57
  start_background_fetch(model, main_queue)
@@ -52,7 +63,7 @@ module Cwt
52
63
  end
53
64
 
54
65
  event = tui.poll_event(timeout: 0.1)
55
-
66
+
56
67
  # Process TUI Event
57
68
  cmd = nil
58
69
  if event.key?
@@ -62,18 +73,24 @@ module Cwt
62
73
  elsif event.none?
63
74
  cmd = Update.handle(model, { type: :tick })
64
75
  end
65
-
76
+
66
77
  handle_command(cmd, model, tui, main_queue) if cmd
67
78
 
68
79
  # Process Background Queue
69
80
  while !main_queue.empty?
70
81
  msg = main_queue.pop(true) rescue nil
71
- if msg
72
- Update.handle(model, msg)
73
- end
82
+ Update.handle(model, msg) if msg
74
83
  end
75
84
  end
76
85
  end
86
+
87
+ # After TUI exits, cd into last worktree if one was resumed
88
+ if model.resume_to && model.resume_to.exists?
89
+ Dir.chdir(model.resume_to.path)
90
+ # OSC 7 tells terminal emulators (Ghostty, tmux, iTerm2) the CWD for new panes
91
+ print "\e]7;file://localhost#{model.resume_to.path}\e\\"
92
+ exec ENV.fetch('SHELL', '/bin/zsh')
93
+ end
77
94
  end
78
95
 
79
96
  def self.handle_command(cmd, model, tui, main_queue)
@@ -88,11 +105,18 @@ module Cwt
88
105
  case cmd[:type]
89
106
  when :quit
90
107
  model.quit
91
- when :create_worktree, :delete_worktree, :refresh_list
108
+ when :delete_worktree
109
+ # Suspend TUI for visible teardown output
110
+ RatatuiRuby.restore_terminal
111
+ puts "\e[H\e[2J" # Clear screen
112
+ result = Update.handle(model, cmd)
113
+ RatatuiRuby.init_terminal
114
+ handle_command(result, model, tui, main_queue)
115
+ when :create_worktree, :refresh_list
92
116
  result = Update.handle(model, cmd)
93
117
  handle_command(result, model, tui, main_queue)
94
118
  when :resume_worktree, :suspend_and_resume
95
- suspend_tui_and_run(cmd[:path], tui)
119
+ suspend_tui_and_run(cmd[:worktree], model, tui)
96
120
  Update.refresh_list(model)
97
121
  start_background_fetch(model, main_queue)
98
122
  end
@@ -104,20 +128,19 @@ module Cwt
104
128
  current_gen = model.fetch_generation
105
129
 
106
130
  worktrees = model.worktrees
107
-
108
- # Batch fetch commit ages (Fast enough to do on main thread or one-off thread?
109
- # Git.get_commit_ages is fast. Let's do it in a one-off thread to not block UI)
131
+
132
+ # Batch fetch commit ages in background thread
110
133
  Thread.new do
111
- shas = worktrees.map { |wt| wt[:sha] }.compact
112
- ages = Git.get_commit_ages(shas)
113
-
134
+ shas = worktrees.map(&:sha).compact
135
+ ages = Git.get_commit_ages(shas, repo_root: model.repository.root)
136
+
114
137
  worktrees.each do |wt|
115
- if age = ages[wt[:sha]]
116
- main_queue << {
117
- type: :update_commit_age,
118
- path: wt[:path],
119
- age: age,
120
- generation: current_gen
138
+ if (age = ages[wt.sha])
139
+ main_queue << {
140
+ type: :update_commit_age,
141
+ path: wt.path,
142
+ age: age,
143
+ generation: current_gen
121
144
  }
122
145
  end
123
146
  end
@@ -125,29 +148,44 @@ module Cwt
125
148
 
126
149
  # Queue Status Checks (Worker Pool)
127
150
  worktrees.each do |wt|
128
- @worker_queue << {
129
- type: :fetch_status,
130
- path: wt[:path],
131
- result_queue: main_queue,
132
- generation: current_gen
151
+ @worker_queue << {
152
+ type: :fetch_status,
153
+ path: wt.path,
154
+ result_queue: main_queue,
155
+ generation: current_gen
133
156
  }
134
157
  end
135
158
  end
136
159
 
137
- def self.suspend_tui_and_run(path, tui)
160
+ def self.suspend_tui_and_run(worktree, model, tui)
138
161
  RatatuiRuby.restore_terminal
139
-
162
+
140
163
  puts "\e[H\e[2J" # Clear screen
141
- puts "Resuming session in #{path}..."
164
+
165
+ # Run setup if this is a new worktree
166
+ if worktree.needs_setup?
167
+ begin
168
+ worktree.run_setup!(visible: true)
169
+ worktree.mark_setup_complete!
170
+ rescue Interrupt
171
+ puts "\nSetup aborted."
172
+ RatatuiRuby.init_terminal
173
+ return
174
+ end
175
+ end
176
+
177
+ puts "Launching claude in #{worktree.path}..."
142
178
  begin
143
- Dir.chdir(path) do
179
+ Dir.chdir(worktree.path) do
144
180
  if defined?(Bundler)
145
181
  Bundler.with_unbundled_env { system("claude") }
146
182
  else
147
183
  system("claude")
148
184
  end
149
185
  end
150
- rescue => e
186
+ # Track last resumed worktree for exit
187
+ model.resume_to = worktree
188
+ rescue StandardError => e
151
189
  puts "Error: #{e.message}"
152
190
  print "Press any key to return..."
153
191
  STDIN.getc
@@ -156,4 +194,4 @@ module Cwt
156
194
  end
157
195
  end
158
196
  end
159
- end
197
+ end
data/lib/cwt/git.rb CHANGED
@@ -1,35 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
- require 'fileutils'
5
4
 
6
5
  module Cwt
6
+ # Thin wrapper around git commands.
7
+ # Business logic lives in Repository and Worktree classes.
7
8
  class Git
8
- WORKTREE_DIR = ".worktrees"
9
-
10
- def self.list_worktrees
11
- # -C . ensures we run from current dir, though we are usually there.
12
- stdout, status = Open3.capture2("git", "worktree", "list", "--porcelain")
13
- return [] unless status.success?
14
-
15
- parse_porcelain(stdout)
16
- end
17
-
18
- def self.get_commit_ages(shas)
9
+ def self.get_commit_ages(shas, repo_root: nil)
19
10
  return {} if shas.empty?
20
-
11
+
21
12
  # Batch fetch commit times
22
13
  # %H: full hash, %cr: relative date
23
- cmd = ["git", "--no-optional-locks", "show", "-s", "--format=%H|%cr"] + shas
14
+ cmd = ["git"]
15
+ cmd += ["-C", repo_root] if repo_root
16
+ cmd += ["--no-optional-locks", "show", "-s", "--format=%H|%cr"] + shas
17
+
24
18
  stdout, status = Open3.capture2(*cmd)
25
19
  return {} unless status.success?
26
20
 
27
21
  ages = {}
28
22
  stdout.each_line do |line|
29
23
  parts = line.strip.split('|')
30
- if parts.size == 2
31
- ages[parts[0]] = parts[1]
32
- end
24
+ ages[parts[0]] = parts[1] if parts.size == 2
33
25
  end
34
26
  ages
35
27
  end
@@ -39,143 +31,20 @@ module Cwt
39
31
  # --no-optional-locks: Prevent git from writing to the index (lock contention)
40
32
  # -C path: Run git in that directory
41
33
  # --porcelain: stable output
42
-
43
34
  dirty_cmd = ["git", "--no-optional-locks", "-C", path, "status", "--porcelain"]
44
35
  stdout_dirty, status_dirty = Open3.capture2(*dirty_cmd)
45
36
  is_dirty = status_dirty.success? && !stdout_dirty.strip.empty?
46
37
 
47
38
  { dirty: is_dirty }
48
- rescue => e
39
+ rescue StandardError
49
40
  { dirty: false }
50
41
  end
51
42
 
52
- def self.add_worktree(name)
53
- # Sanitize name
54
- safe_name = name.strip.gsub(/[^a-zA-Z0-9_\-]/, '_')
55
- path = File.join(WORKTREE_DIR, safe_name)
56
-
57
- # Ensure .worktrees exists
58
- FileUtils.mkdir_p(WORKTREE_DIR)
59
-
60
- # Create worktree
61
- # We create a new branch with the same name as the worktree
62
- cmd = ["git", "worktree", "add", "-b", safe_name, path]
63
- _stdout, stderr, status = Open3.capture3(*cmd)
64
-
65
- unless status.success?
66
- return { success: false, error: stderr }
67
- end
68
-
69
- # Post-creation setup: Copy .env if it exists
70
- setup_environment(path)
71
-
72
- { success: true, path: path }
73
- end
74
-
75
- def self.remove_worktree(path, force: false)
76
- # Step 1: Cleanup symlinks/copies (Best effort)
77
- # This helps 'safe delete' succeed if only untracked files are present.
78
- [".env", "node_modules"].each do |file|
79
- target_path = File.join(path, file)
80
- if File.exist?(target_path)
81
- File.delete(target_path) rescue nil
82
- end
83
- end
84
-
85
- # Step 2: Remove Worktree
86
- # Only attempt if the directory actually exists.
87
- # This handles the "Phantom Branch" case (worktree gone, branch remains).
88
- if Dir.exist?(path)
89
- wt_cmd = ["git", "--no-optional-locks", "worktree", "remove", path]
90
- wt_cmd << "--force" if force
91
-
92
- stdout, stderr, status = Open3.capture3(*wt_cmd)
93
-
94
- unless status.success?
95
- # If we failed to remove the worktree, we must stop unless it's a "not found" error.
96
- # But "not found" should be covered by Dir.exist? check mostly.
97
- # If git complains about dirty files, we stop here (unless force was used, which is handled by --force).
98
- return { success: false, error: stderr.strip }
99
- end
100
- end
101
-
102
- # Step 3: Delete Branch
103
- # The branch name is usually the basename of the path.
104
- branch_name = File.basename(path)
105
-
106
- branch_flag = force ? "-D" : "-d"
107
- stdout_b, stderr_b, status_b = Open3.capture3("git", "branch", branch_flag, branch_name)
108
-
109
- if status_b.success?
110
- { success: true }
111
- else
112
- # Branch deletion failed.
113
- if force
114
- # Force delete failed. This is weird (maybe branch doesn't exist?).
115
- # If branch doesn't exist, we can consider it success?
116
- if stderr_b.include?("not found")
117
- { success: true }
118
- else
119
- { success: false, error: "Worktree removed, but branch delete failed: #{stderr_b.strip}" }
120
- end
121
- else
122
- # Safe delete failed (unmerged commits).
123
- # This is a valid state: Worktree is gone, but branch remains to save data.
124
- { success: true, warning: "Worktree removed, but branch kept (unmerged). Use 'D' to force." }
125
- end
126
- end
127
- end
128
-
129
- def self.prune_worktrees
130
- Open3.capture2("git", "worktree", "prune")
131
- end
132
-
133
- private
134
-
135
- def self.parse_porcelain(output)
136
- worktrees = []
137
- current = {}
138
-
139
- output.each_line do |line|
140
- if line.start_with?("worktree ")
141
- if current.any?
142
- worktrees << current
143
- current = {}
144
- end
145
- current[:path] = line.sub("worktree ", "").strip
146
- elsif line.start_with?("HEAD ")
147
- current[:sha] = line.sub("HEAD ", "").strip
148
- elsif line.start_with?("branch ")
149
- current[:branch] = line.sub("branch ", "").strip.sub("refs/heads/", "")
150
- end
151
- end
152
- worktrees << current if current.any?
153
- worktrees
154
- end
155
-
156
- def self.setup_environment(target_path)
157
- root = Dir.pwd
158
- setup_script = File.join(root, ".cwt", "setup")
159
-
160
- # 1. Custom Setup Script
161
- if File.exist?(setup_script) && File.executable?(setup_script)
162
- # Execute the script inside the new worktree
163
- # passing the root path as an argument might be helpful, but relying on relative paths is standard.
164
- Open3.capture2(setup_script, chdir: target_path)
165
- return
166
- end
167
-
168
- # 2. Default Behavior: Symlink .env and node_modules
169
- files_to_link = [".env", "node_modules"]
170
-
171
- files_to_link.each do |file|
172
- source = File.join(root, file)
173
- target = File.join(target_path, file)
174
-
175
- if File.exist?(source) && !File.exist?(target)
176
- FileUtils.ln_s(source, target)
177
- end
178
- end
43
+ def self.prune_worktrees(repo_root: nil)
44
+ cmd = ["git"]
45
+ cmd += ["-C", repo_root] if repo_root
46
+ cmd << "worktree" << "prune"
47
+ Open3.capture2(*cmd)
179
48
  end
180
49
  end
181
- end
50
+ end
data/lib/cwt/model.rb CHANGED
@@ -1,9 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Cwt
2
4
  class Model
3
- attr_reader :worktrees, :selection_index, :mode, :input_buffer, :message, :running, :fetch_generation, :filter_query
5
+ attr_reader :repository, :selection_index, :mode, :input_buffer, :message, :running, :fetch_generation, :filter_query
6
+ attr_accessor :resume_to # Worktree object or nil
4
7
 
5
- def initialize
6
- @worktrees = []
8
+ def initialize(repository)
9
+ @repository = repository
10
+ @worktrees_cache = []
7
11
  @selection_index = 0
8
12
  @mode = :normal # :normal, :creating, :filtering
9
13
  @input_buffer = String.new
@@ -11,18 +15,41 @@ module Cwt
11
15
  @message = "Welcome to CWT"
12
16
  @running = true
13
17
  @fetch_generation = 0
18
+ @resume_to = nil
19
+ end
20
+
21
+ def worktrees
22
+ @worktrees_cache
23
+ end
24
+
25
+ def refresh_worktrees!
26
+ @worktrees_cache = @repository.worktrees
27
+ clamp_selection
28
+ @worktrees_cache
14
29
  end
15
30
 
16
31
  def update_worktrees(list)
17
- @worktrees = list
32
+ @worktrees_cache = list
18
33
  clamp_selection
19
34
  end
20
35
 
36
+ def find_worktree_by_path(path)
37
+ # Normalize path for comparison (handles macOS /var -> /private/var symlinks)
38
+ normalized = begin
39
+ File.realpath(path)
40
+ rescue Errno::ENOENT
41
+ File.expand_path(path)
42
+ end
43
+ @worktrees_cache.find { |wt| wt.path == normalized }
44
+ end
45
+
21
46
  def visible_worktrees
22
47
  if @filter_query.empty?
23
- @worktrees
48
+ @worktrees_cache
24
49
  else
25
- @worktrees.select { |wt| wt[:path].include?(@filter_query) || (wt[:branch] && wt[:branch].include?(@filter_query)) }
50
+ @worktrees_cache.select do |wt|
51
+ wt.path.include?(@filter_query) || (wt.branch && wt.branch.include?(@filter_query))
52
+ end
26
53
  end
27
54
  end
28
55
 
@@ -33,7 +60,7 @@ module Cwt
33
60
  def move_selection(delta)
34
61
  list = visible_worktrees
35
62
  return if list.empty?
36
-
63
+
37
64
  new_index = @selection_index + delta
38
65
  if new_index >= 0 && new_index < list.size
39
66
  @selection_index = new_index
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module Cwt
7
+ class Repository
8
+ WORKTREE_DIR = ".worktrees"
9
+ CONFIG_DIR = ".cwt"
10
+
11
+ attr_reader :root
12
+
13
+ # Find repo root from any path (including from within worktrees)
14
+ def self.discover(start_path = Dir.pwd)
15
+ Dir.chdir(start_path) do
16
+ stdout, status = Open3.capture2("git", "rev-parse", "--path-format=absolute", "--git-common-dir")
17
+ return nil unless status.success?
18
+
19
+ git_common_dir = stdout.strip
20
+ return nil if git_common_dir.empty?
21
+
22
+ # --git-common-dir returns /path/to/repo/.git, so strip the /.git
23
+ new(git_common_dir.sub(%r{/\.git$}, ''))
24
+ end
25
+ rescue Errno::ENOENT
26
+ nil
27
+ end
28
+
29
+ def initialize(root)
30
+ @root = File.expand_path(root)
31
+ end
32
+
33
+ def worktrees_dir
34
+ File.join(@root, WORKTREE_DIR)
35
+ end
36
+
37
+ def config_dir
38
+ File.join(@root, CONFIG_DIR)
39
+ end
40
+
41
+ def setup_script_path
42
+ File.join(config_dir, "setup")
43
+ end
44
+
45
+ def teardown_script_path
46
+ File.join(config_dir, "teardown")
47
+ end
48
+
49
+ def has_setup_script?
50
+ File.exist?(setup_script_path) && File.executable?(setup_script_path)
51
+ end
52
+
53
+ def has_teardown_script?
54
+ File.exist?(teardown_script_path) && File.executable?(teardown_script_path)
55
+ end
56
+
57
+ # Returns Array<Worktree>
58
+ def worktrees
59
+ require_relative 'worktree'
60
+
61
+ stdout, status = Open3.capture2("git", "-C", @root, "worktree", "list", "--porcelain")
62
+ return [] unless status.success?
63
+
64
+ parse_porcelain(stdout).map do |data|
65
+ Worktree.new(
66
+ repository: self,
67
+ path: data[:path],
68
+ branch: data[:branch],
69
+ sha: data[:sha]
70
+ )
71
+ end
72
+ end
73
+
74
+ def find_worktree(name_or_path)
75
+ # Normalize path for comparison (handles macOS /var -> /private/var symlinks)
76
+ normalized_path = begin
77
+ File.realpath(name_or_path)
78
+ rescue Errno::ENOENT
79
+ File.expand_path(name_or_path)
80
+ end
81
+
82
+ worktrees.find do |wt|
83
+ wt.name == name_or_path || wt.path == normalized_path
84
+ end
85
+ end
86
+
87
+ # Create a new worktree with the given name
88
+ # Returns { success: true, worktree: Worktree } or { success: false, error: String }
89
+ def create_worktree(name)
90
+ require_relative 'worktree'
91
+
92
+ # Sanitize name
93
+ safe_name = name.strip.gsub(/[^a-zA-Z0-9_\-]/, '_')
94
+ path = File.join(worktrees_dir, safe_name)
95
+ absolute_path = File.join(@root, WORKTREE_DIR, safe_name)
96
+
97
+ # Ensure .worktrees exists
98
+ FileUtils.mkdir_p(worktrees_dir)
99
+
100
+ # Create worktree with new branch
101
+ cmd = ["git", "-C", @root, "worktree", "add", "-b", safe_name, path]
102
+ _stdout, stderr, status = Open3.capture3(*cmd)
103
+
104
+ unless status.success?
105
+ return { success: false, error: stderr }
106
+ end
107
+
108
+ # Create worktree object
109
+ worktree = Worktree.new(
110
+ repository: self,
111
+ path: absolute_path,
112
+ branch: safe_name,
113
+ sha: nil # Will be populated on next list
114
+ )
115
+
116
+ # Mark as needing setup
117
+ worktree.mark_needs_setup!
118
+
119
+ { success: true, worktree: worktree }
120
+ end
121
+
122
+ private
123
+
124
+ def parse_porcelain(output)
125
+ worktrees = []
126
+ current = {}
127
+
128
+ output.each_line do |line|
129
+ if line.start_with?("worktree ")
130
+ if current.any?
131
+ worktrees << current
132
+ current = {}
133
+ end
134
+ current[:path] = line.sub("worktree ", "").strip
135
+ elsif line.start_with?("HEAD ")
136
+ current[:sha] = line.sub("HEAD ", "").strip
137
+ elsif line.start_with?("branch ")
138
+ current[:branch] = line.sub("branch ", "").strip.sub("refs/heads/", "")
139
+ end
140
+ end
141
+ worktrees << current if current.any?
142
+ worktrees
143
+ end
144
+ end
145
+ end
data/lib/cwt/update.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "git"
4
-
5
3
  module Cwt
6
4
  class Update
7
5
  def self.handle(model, message)
@@ -16,26 +14,24 @@ module Cwt
16
14
  refresh_list(model)
17
15
  :start_background_fetch
18
16
  when :create_worktree
19
- result = Git.add_worktree(message[:name])
17
+ result = model.repository.create_worktree(message[:name])
20
18
  if result[:success]
21
19
  model.set_message("Created worktree: #{message[:name]}")
22
20
  refresh_list(model)
23
21
  model.set_mode(:normal)
24
22
  model.set_filter(String.new) # Clear filter
25
23
  # Auto-enter the new session
26
- # We return the resume command directly.
27
- # The App will execute resume, which involves suspend->run->restore->refresh->fetch
28
- { type: :resume_worktree, path: result[:path] }
24
+ { type: :resume_worktree, worktree: result[:worktree] }
29
25
  else
30
26
  model.set_message("Error: #{result[:error]}")
31
27
  nil
32
28
  end
33
29
  when :delete_worktree
34
- path = message[:path]
30
+ worktree = message[:worktree]
35
31
  force = message[:force] || false
36
-
37
- result = Git.remove_worktree(path, force: force)
38
-
32
+
33
+ result = worktree.delete!(force: force)
34
+
39
35
  if result[:success]
40
36
  if result[:warning]
41
37
  model.set_message("Warning: #{result[:warning]}. Use 'D' to force delete.")
@@ -49,22 +45,18 @@ module Cwt
49
45
  nil
50
46
  end
51
47
  when :resume_worktree
52
- { type: :suspend_and_resume, path: message[:path] }
48
+ { type: :suspend_and_resume, worktree: message[:worktree] }
53
49
  when :update_status
54
50
  return nil if message[:generation] != model.fetch_generation
55
-
56
- target = model.worktrees.find { |wt| wt[:path] == message[:path] }
57
- if target
58
- target[:dirty] = message[:status][:dirty]
59
- end
51
+
52
+ target = model.find_worktree_by_path(message[:path])
53
+ target.dirty = message[:status][:dirty] if target
60
54
  nil
61
55
  when :update_commit_age
62
56
  return nil if message[:generation] != model.fetch_generation
63
57
 
64
- target = model.worktrees.find { |wt| wt[:path] == message[:path] }
65
- if target
66
- target[:last_commit] = message[:age]
67
- end
58
+ target = model.find_worktree_by_path(message[:path])
59
+ target.last_commit = message[:age] if target
68
60
  nil
69
61
  end
70
62
  end
@@ -85,10 +77,9 @@ module Cwt
85
77
  # Select current item and resume
86
78
  wt = model.selected_worktree
87
79
  if wt
88
- path = wt[:path]
89
80
  model.set_filter(String.new) # Clear filter
90
81
  model.set_mode(:normal) # Exit filter mode on selection
91
- return { type: :resume_worktree, path: path }
82
+ return { type: :resume_worktree, worktree: wt }
92
83
  else
93
84
  model.set_mode(:normal)
94
85
  end
@@ -118,16 +109,15 @@ module Cwt
118
109
  model.set_mode(:filtering)
119
110
  elsif event.d?
120
111
  wt = model.selected_worktree
121
- return { type: :delete_worktree, path: wt[:path], force: false } if wt
112
+ return { type: :delete_worktree, worktree: wt, force: false } if wt
122
113
  elsif event.D? # Shift+d
123
114
  wt = model.selected_worktree
124
- return { type: :delete_worktree, path: wt[:path], force: true } if wt
115
+ return { type: :delete_worktree, worktree: wt, force: true } if wt
125
116
  elsif event.enter?
126
117
  wt = model.selected_worktree
127
118
  if wt
128
- path = wt[:path]
129
119
  model.set_filter(String.new) # Clear filter on resume
130
- return { type: :resume_worktree, path: path }
120
+ return { type: :resume_worktree, worktree: wt }
131
121
  end
132
122
  elsif event.r?
133
123
  return { type: :refresh_list }
@@ -137,8 +127,7 @@ module Cwt
137
127
  end
138
128
 
139
129
  def self.refresh_list(model)
140
- list = Git.list_worktrees
141
- model.update_worktrees(list)
130
+ model.refresh_worktrees!
142
131
  end
143
132
  end
144
133
  end
data/lib/cwt/view.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../claude/worktree/version'
4
+
3
5
  module Cwt
4
6
  class View
5
7
  THEME = {
@@ -15,16 +17,8 @@ module Cwt
15
17
  }.freeze
16
18
 
17
19
  def self.draw(model, tui, frame)
18
- # 1. Calculate App Area (Centered, Max Width/Height)
19
- # Use full height but constrained width? Or constrained height too?
20
- # User said "Keybindings section is so far...".
21
- # If I have 3 items on a 60 row terminal, the footer is at row 60.
22
- # I should shrink the height to fit content if possible.
23
-
24
20
  content_height = [model.visible_worktrees.size + 6, frame.area.height].min
25
- # +6 accounts for Header(3) + Footer(3).
26
-
27
- # Let's enforce a minimum width/height for usability
21
+
28
22
  app_area = centered_app_area(tui, frame.area, width: 100, height: content_height)
29
23
 
30
24
  main_area, footer_area = tui.layout_split(
@@ -49,34 +43,29 @@ module Cwt
49
43
  draw_list(model, tui, frame, list_area)
50
44
  draw_footer(model, tui, frame, footer_area)
51
45
 
52
- if model.mode == :creating
53
- draw_input_modal(model, tui, frame)
54
- end
46
+ return unless model.mode == :creating
47
+
48
+ draw_input_modal(model, tui, frame)
55
49
  end
56
50
 
57
51
  def self.centered_app_area(tui, area, width:, height:)
58
- # Calculate width
59
- w = [area.width, width].min
60
-
61
- # Calculate height (min 15 lines to avoid jarring jumps)
62
- h = [[area.height, height].min, 15].max
63
- h = [h, area.height].min # Ensure we don't exceed terminal
64
-
65
- # Center it
66
- x = area.x + (area.width - w) / 2
67
- y = area.y + (area.height - h) / 2
68
-
69
- tui.rect(x: x, y: y, width: w, height: h)
52
+ w = [area.width, width].min
53
+ h = [[area.height, height].min, 15].max
54
+ h = [h, area.height].min
55
+
56
+ x = area.x + (area.width - w) / 2
57
+ y = area.y + (area.height - h) / 2
58
+
59
+ tui.rect(x: x, y: y, width: w, height: h)
70
60
  end
71
61
 
72
62
  def self.draw_header(tui, frame, area)
73
- # Modern, minimalist header
74
63
  title = tui.paragraph(
75
- text: " CWT • WORKTREE MANAGER ",
64
+ text: " CWT v#{Claude::Worktree::VERSION} • WORKTREE MANAGER ",
76
65
  alignment: :center,
77
66
  style: tui.style(**THEME[:header]),
78
67
  block: tui.block(
79
- borders: [:bottom],
68
+ borders: [:bottom],
80
69
  border_style: tui.style(**THEME[:border])
81
70
  )
82
71
  )
@@ -85,23 +74,17 @@ module Cwt
85
74
 
86
75
  def self.draw_list(model, tui, frame, area)
87
76
  items = model.visible_worktrees.map do |wt|
88
- name = File.basename(wt[:path])
89
- branch = wt[:branch] || "HEAD"
90
-
91
77
  # Status Icons
92
- # Using simple unicode that is generally supported
93
- status_icon = wt[:dirty] ? "●" : " "
94
- status_style = wt[:dirty] ? tui.style(**THEME[:dirty]) : tui.style(**THEME[:clean])
95
-
96
- time = wt[:last_commit] || ""
97
-
78
+ status_icon = wt.dirty ? '●' : ' '
79
+ status_style = wt.dirty ? tui.style(**THEME[:dirty]) : tui.style(**THEME[:clean])
80
+
81
+ time = wt.last_commit || ''
82
+
98
83
  # Consistent Column Widths
99
- # Name: 25, Branch: 25, Time: 15, Status: 2
100
-
101
84
  tui.text_line(spans: [
102
85
  tui.text_span(content: " #{status_icon} ", style: status_style),
103
- tui.text_span(content: name.ljust(25), style: tui.style(modifiers: [:bold])),
104
- tui.text_span(content: branch.ljust(25), style: tui.style(**THEME[:dim])),
86
+ tui.text_span(content: wt.name.ljust(25), style: tui.style(modifiers: [:bold])),
87
+ tui.text_span(content: (wt.branch || 'HEAD').ljust(25), style: tui.style(**THEME[:dim])),
105
88
  tui.text_span(content: time.rjust(15), style: tui.style(**THEME[:accent]))
106
89
  ])
107
90
  end
@@ -109,77 +92,66 @@ module Cwt
109
92
  # Dynamic Title based on context
110
93
  title_content = if model.mode == :filtering
111
94
  tui.text_line(spans: [
112
- tui.text_span(content: " FILTERING: ", style: tui.style(**THEME[:accent])),
113
- tui.text_span(content: model.filter_query, style: tui.style(fg: :white, modifiers: [:bold]))
95
+ tui.text_span(content: ' FILTERING: ', style: tui.style(**THEME[:accent])),
96
+ tui.text_span(content: model.filter_query,
97
+ style: tui.style(
98
+ fg: :white, modifiers: [:bold]
99
+ ))
114
100
  ])
115
101
  else
116
- tui.text_line(spans: [tui.text_span(content: " SESSIONS ", style: tui.style(**THEME[:dim]))])
102
+ tui.text_line(spans: [tui.text_span(content: ' SESSIONS ', style: tui.style(**THEME[:dim]))])
117
103
  end
118
104
 
119
105
  list = tui.list(
120
106
  items: items,
121
107
  selected_index: model.selection_index,
122
108
  highlight_style: tui.style(**THEME[:selection]),
123
- highlight_symbol: "", # Modern block cursor
109
+ highlight_symbol: '',
124
110
  block: tui.block(
125
- titles: [ { content: title_content } ],
111
+ titles: [{ content: title_content }],
126
112
  borders: [:all],
127
113
  border_style: tui.style(**THEME[:border])
128
114
  )
129
115
  )
130
-
116
+
131
117
  frame.render_widget(list, area)
132
118
  end
133
119
 
134
120
  def self.draw_footer(model, tui, frame, area)
135
121
  keys = []
136
-
137
- # Helper to build consistent key hints
138
- add_key = ->(key, desc) {
122
+
123
+ add_key = lambda { |key, desc|
139
124
  keys << tui.text_span(content: " #{key} ", style: tui.style(bg: :dark_gray, fg: :white))
140
125
  keys << tui.text_span(content: " #{desc} ", style: tui.style(**THEME[:dim]))
141
126
  }
142
127
 
143
128
  case model.mode
144
129
  when :creating
145
- add_key.call("Enter", "Confirm")
146
- add_key.call("Esc", "Cancel")
130
+ add_key.call('Enter', 'Confirm')
131
+ add_key.call('Esc', 'Cancel')
147
132
  when :filtering
148
- add_key.call("Type", "Search")
149
- add_key.call("Enter", "Select")
150
- add_key.call("Esc", "Reset")
133
+ add_key.call('Type', 'Search')
134
+ add_key.call('Enter', 'Select')
135
+ add_key.call('Esc', 'Reset')
151
136
  else
152
- add_key.call("n", "New")
153
- add_key.call("/", "Filter")
154
- add_key.call("Enter", "Resume")
155
- add_key.call("d", "Delete")
156
- add_key.call("q", "Quit")
137
+ add_key.call('n', 'New')
138
+ add_key.call('/', 'Filter')
139
+ add_key.call('Enter', 'Resume')
140
+ add_key.call('d', 'Delete')
141
+ add_key.call('q', 'Quit')
157
142
  end
158
143
 
159
- # Status Message on the right (if we calculate length, but Paragraph handles simple text)
160
- # We'll put message on top line, keys on bottom line of the footer block?
161
- # Or just inline them.
162
-
163
- # Let's do: Message .... [Keys]
164
- # But Ratatui Paragraph is simple.
165
-
166
- # Actually, let's just render the keys. The message is overlayed or separate.
167
- # The previous implementation combined them.
168
-
169
- # Let's put the message in the title of the footer, and keys in the body?
170
- # Or Message left, keys right?
171
-
172
- # Simple approach: Line 1: Message (highlighted if warning). Line 2: Keys.
173
-
174
- msg_style = model.message.downcase.include?("error") || model.message.downcase.include?("warning") ?
175
- tui.style(fg: :red, modifiers: [:bold]) :
176
- tui.style(**THEME[:accent])
144
+ msg_style = if model.message.downcase.include?('error') || model.message.downcase.include?('warning')
145
+ tui.style(fg: :red, modifiers: [:bold])
146
+ else
147
+ tui.style(**THEME[:accent])
148
+ end
177
149
 
178
150
  text = [
179
151
  tui.text_line(spans: [tui.text_span(content: model.message, style: msg_style)]),
180
152
  tui.text_line(spans: keys)
181
153
  ]
182
-
154
+
183
155
  footer = tui.paragraph(
184
156
  text: text,
185
157
  block: tui.block(
@@ -187,27 +159,26 @@ module Cwt
187
159
  border_style: tui.style(**THEME[:border])
188
160
  )
189
161
  )
190
-
162
+
191
163
  frame.render_widget(footer, area)
192
164
  end
193
165
 
194
166
  def self.draw_input_modal(model, tui, frame)
195
167
  area = center_rect(tui, frame.area, 50, 3)
196
-
197
- # Create a "shadow" or clear background
168
+
198
169
  frame.render_widget(tui.clear, area)
199
-
170
+
200
171
  input = tui.paragraph(
201
172
  text: model.input_buffer,
202
173
  style: tui.style(fg: :white),
203
174
  block: tui.block(
204
- title: " NEW SESSION ",
175
+ title: ' NEW SESSION ',
205
176
  title_style: tui.style(fg: :blue, modifiers: [:bold]),
206
177
  borders: [:all],
207
178
  border_style: tui.style(**THEME[:modal_border])
208
179
  )
209
180
  )
210
-
181
+
211
182
  frame.render_widget(input, area)
212
183
  end
213
184
 
@@ -235,4 +206,4 @@ module Cwt
235
206
  horiz[1]
236
207
  end
237
208
  end
238
- end
209
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module Cwt
7
+ class Worktree
8
+ SETUP_MARKER = ".cwt_needs_setup"
9
+ DEFAULT_SYMLINKS = [".env", "node_modules"].freeze
10
+
11
+ attr_reader :repository, :path, :branch, :sha
12
+ attr_accessor :dirty, :last_commit
13
+
14
+ def initialize(repository:, path:, branch:, sha:)
15
+ @repository = repository
16
+ @path = File.expand_path(path)
17
+ @branch = branch
18
+ @sha = sha
19
+ @dirty = nil
20
+ @last_commit = nil
21
+ end
22
+
23
+ def name
24
+ File.basename(@path)
25
+ end
26
+
27
+ def exists?
28
+ Dir.exist?(@path)
29
+ end
30
+
31
+ def needs_setup?
32
+ File.exist?(setup_marker_path)
33
+ end
34
+
35
+ def mark_needs_setup!
36
+ FileUtils.touch(setup_marker_path)
37
+ end
38
+
39
+ def mark_setup_complete!
40
+ File.delete(setup_marker_path) if File.exist?(setup_marker_path)
41
+ end
42
+
43
+ # Run setup script or default symlinks
44
+ # visible: true shows output to user, false runs silently
45
+ def run_setup!(visible: true)
46
+ if @repository.has_setup_script?
47
+ run_custom_setup(visible: visible)
48
+ else
49
+ setup_default_symlinks
50
+ end
51
+ end
52
+
53
+ # Run teardown script if it exists
54
+ # Returns { ran: Boolean, success: Boolean }
55
+ def run_teardown!
56
+ return { ran: false } unless @repository.has_teardown_script?
57
+
58
+ puts "\e[1;36m=== Running .cwt/teardown ===\e[0m"
59
+ puts
60
+
61
+ success = Dir.chdir(@path) do
62
+ system({ "CWT_ROOT" => File.realpath(@repository.root) }, @repository.teardown_script_path)
63
+ end
64
+
65
+ puts
66
+
67
+ { ran: true, success: success }
68
+ end
69
+
70
+ # Delete this worktree and its branch
71
+ # force: true to force delete even with uncommitted changes
72
+ # Returns { success: Boolean, error: String?, warning: String? }
73
+ def delete!(force: false)
74
+ # Step 0: Run teardown script if directory exists
75
+ if exists?
76
+ result = run_teardown!
77
+ if result[:ran] && !result[:success] && !force
78
+ return { success: false, error: "Teardown script failed. Use 'D' to force delete." }
79
+ end
80
+ end
81
+
82
+ # Step 1: Cleanup symlinks/copies (Best effort)
83
+ cleanup_symlinks
84
+
85
+ # Step 2: Remove Worktree
86
+ if exists?
87
+ wt_cmd = ["git", "-C", @repository.root, "worktree", "remove", @path]
88
+ wt_cmd << "--force" if force
89
+
90
+ _stdout, stderr, status = Open3.capture3(*wt_cmd)
91
+
92
+ unless status.success?
93
+ return { success: false, error: stderr.strip }
94
+ end
95
+ end
96
+
97
+ # Step 3: Delete Branch
98
+ delete_branch(force: force)
99
+ end
100
+
101
+ # Fetch status (dirty flag) from git
102
+ def fetch_status!
103
+ stdout, status = Open3.capture2(
104
+ "git", "--no-optional-locks", "-C", @path, "status", "--porcelain"
105
+ )
106
+ @dirty = status.success? && !stdout.strip.empty?
107
+ @dirty
108
+ rescue StandardError
109
+ @dirty = false
110
+ end
111
+
112
+ # Hash representation for compatibility
113
+ def to_h
114
+ {
115
+ path: @path,
116
+ branch: @branch,
117
+ sha: @sha,
118
+ dirty: @dirty,
119
+ last_commit: @last_commit
120
+ }
121
+ end
122
+
123
+ private
124
+
125
+ def setup_marker_path
126
+ File.join(@path, SETUP_MARKER)
127
+ end
128
+
129
+ def run_custom_setup(visible: true)
130
+ if visible
131
+ puts "\e[1;36m=== Running .cwt/setup ===\e[0m"
132
+ puts
133
+ end
134
+
135
+ success = Dir.chdir(@path) do
136
+ system({ "CWT_ROOT" => File.realpath(@repository.root) }, @repository.setup_script_path)
137
+ end
138
+
139
+ puts if visible
140
+
141
+ unless success
142
+ if visible
143
+ puts "\e[1;33mWarning: .cwt/setup failed (exit code: #{$?.exitstatus})\e[0m"
144
+ print "Press Enter to continue or Ctrl+C to abort..."
145
+ begin
146
+ STDIN.gets
147
+ rescue Interrupt
148
+ raise
149
+ end
150
+ end
151
+ end
152
+
153
+ success
154
+ end
155
+
156
+ def setup_default_symlinks
157
+ DEFAULT_SYMLINKS.each do |file|
158
+ source = File.join(@repository.root, file)
159
+ target = File.join(@path, file)
160
+
161
+ if File.exist?(source) && !File.exist?(target)
162
+ FileUtils.ln_s(source, target)
163
+ end
164
+ end
165
+ end
166
+
167
+ def cleanup_symlinks
168
+ DEFAULT_SYMLINKS.each do |file|
169
+ target_path = File.join(@path, file)
170
+ File.delete(target_path) if File.exist?(target_path)
171
+ rescue StandardError
172
+ nil
173
+ end
174
+ end
175
+
176
+ def delete_branch(force: false)
177
+ branch_flag = force ? "-D" : "-d"
178
+ _stdout, stderr, status = Open3.capture3(
179
+ "git", "-C", @repository.root, "branch", branch_flag, name
180
+ )
181
+
182
+ if status.success?
183
+ { success: true }
184
+ elsif force
185
+ # Force delete failed - maybe branch doesn't exist
186
+ if stderr.include?("not found")
187
+ { success: true }
188
+ else
189
+ { success: false, error: "Worktree removed, but branch delete failed: #{stderr.strip}" }
190
+ end
191
+ else
192
+ # Safe delete failed (unmerged commits) - worktree gone but branch kept
193
+ { success: true, warning: "Worktree removed, but branch kept (unmerged). Use 'D' to force." }
194
+ end
195
+ end
196
+ end
197
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-worktree
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Garcia
@@ -41,17 +41,19 @@ files:
41
41
  - lib/cwt/app.rb
42
42
  - lib/cwt/git.rb
43
43
  - lib/cwt/model.rb
44
+ - lib/cwt/repository.rb
44
45
  - lib/cwt/update.rb
45
46
  - lib/cwt/view.rb
47
+ - lib/cwt/worktree.rb
46
48
  - sig/claude/worktree.rbs
47
- homepage: https://github.com/bengarcia/claude-worktree
49
+ homepage: https://github.com/bucket-robotics/claude-worktree
48
50
  licenses:
49
51
  - MIT
50
52
  metadata:
51
53
  allowed_push_host: https://rubygems.org
52
- homepage_uri: https://github.com/bengarcia/claude-worktree
53
- source_code_uri: https://github.com/bengarcia/claude-worktree
54
- changelog_uri: https://github.com/bengarcia/claude-worktree/blob/main/CHANGELOG.md
54
+ homepage_uri: https://github.com/bucket-robotics/claude-worktree
55
+ source_code_uri: https://github.com/bucket-robotics/claude-worktree
56
+ changelog_uri: https://github.com/bucket-robotics/claude-worktree/blob/main/CHANGELOG.md
55
57
  rdoc_options: []
56
58
  require_paths:
57
59
  - lib