claude-worktree 0.1.4 → 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: aa2000361072768df44df230f654d22c5bd8e435bceb02bb597e7bbbfc369b28
4
- data.tar.gz: 7c42b53349ffcb6dc3050c68db40b869da936d94c61f4cc25ac7899adbfcdc66
3
+ metadata.gz: 1c13372671d9041cb88fbe74d783caec8d15cba40c9c0395b6ab64ef6105913c
4
+ data.tar.gz: dcf190dcc57792301fdddaf01ac7e880b56a14c97ee67f2095283ddc459b53dd
5
5
  SHA512:
6
- metadata.gz: c2a27c69c6d1117cadefc144e85b04816580f58acd621ce036a69a64f54200c1705c564ed11df4b481ed7d552b1554c10d8a2e7d1dfbb3e7003eb697f812a277
7
- data.tar.gz: 78601cef3ca323a17c8b9333d54405209b7470545c86c15e13b5518cc17764d2edf46ca2b072fc6df4245bdbd47040924fb3fcc23e4ddbacd51e8114467a2fec
6
+ metadata.gz: c1c74c92f54d58d1799bb99fa42526899dccf851c191d3db5ac193f03b49a93438f80866c28a37cd52243722863445d8a0619f81b834d376df434c3e48b722e2
7
+ data.tar.gz: 3c0e53ec321f5dd06aa0d79caec3f2bca13a7647e338f30c354a1a287c2712b4f83ef161f4ce37835b5b3196d547707ee68e7d5cbfeb3da10d35391a97b33036
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.4"
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)
@@ -68,16 +79,16 @@ module Cwt
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
77
86
 
78
87
  # After TUI exits, cd into last worktree if one was resumed
79
- if model.exit_directory && Dir.exist?(model.exit_directory)
80
- Dir.chdir(model.exit_directory)
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\\"
81
92
  exec ENV.fetch('SHELL', '/bin/zsh')
82
93
  end
83
94
  end
@@ -105,7 +116,7 @@ module Cwt
105
116
  result = Update.handle(model, cmd)
106
117
  handle_command(result, model, tui, main_queue)
107
118
  when :resume_worktree, :suspend_and_resume
108
- suspend_tui_and_run(cmd[:path], model, tui)
119
+ suspend_tui_and_run(cmd[:worktree], model, tui)
109
120
  Update.refresh_list(model)
110
121
  start_background_fetch(model, main_queue)
111
122
  end
@@ -117,20 +128,19 @@ module Cwt
117
128
  current_gen = model.fetch_generation
118
129
 
119
130
  worktrees = model.worktrees
120
-
121
- # Batch fetch commit ages (Fast enough to do on main thread or one-off thread?
122
- # 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
123
133
  Thread.new do
124
- shas = worktrees.map { |wt| wt[:sha] }.compact
125
- ages = Git.get_commit_ages(shas)
126
-
134
+ shas = worktrees.map(&:sha).compact
135
+ ages = Git.get_commit_ages(shas, repo_root: model.repository.root)
136
+
127
137
  worktrees.each do |wt|
128
- if age = ages[wt[:sha]]
129
- main_queue << {
130
- type: :update_commit_age,
131
- path: wt[:path],
132
- age: age,
133
- 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
134
144
  }
135
145
  end
136
146
  end
@@ -138,25 +148,25 @@ module Cwt
138
148
 
139
149
  # Queue Status Checks (Worker Pool)
140
150
  worktrees.each do |wt|
141
- @worker_queue << {
142
- type: :fetch_status,
143
- path: wt[:path],
144
- result_queue: main_queue,
145
- generation: current_gen
151
+ @worker_queue << {
152
+ type: :fetch_status,
153
+ path: wt.path,
154
+ result_queue: main_queue,
155
+ generation: current_gen
146
156
  }
147
157
  end
148
158
  end
149
159
 
150
- def self.suspend_tui_and_run(path, model, tui)
160
+ def self.suspend_tui_and_run(worktree, model, tui)
151
161
  RatatuiRuby.restore_terminal
152
162
 
153
163
  puts "\e[H\e[2J" # Clear screen
154
164
 
155
165
  # Run setup if this is a new worktree
156
- if Git.needs_setup?(path)
166
+ if worktree.needs_setup?
157
167
  begin
158
- Git.run_setup_visible(path)
159
- Git.mark_setup_complete(path)
168
+ worktree.run_setup!(visible: true)
169
+ worktree.mark_setup_complete!
160
170
  rescue Interrupt
161
171
  puts "\nSetup aborted."
162
172
  RatatuiRuby.init_terminal
@@ -164,18 +174,18 @@ module Cwt
164
174
  end
165
175
  end
166
176
 
167
- puts "Launching claude in #{path}..."
177
+ puts "Launching claude in #{worktree.path}..."
168
178
  begin
169
- Dir.chdir(path) do
179
+ Dir.chdir(worktree.path) do
170
180
  if defined?(Bundler)
171
181
  Bundler.with_unbundled_env { system("claude") }
172
182
  else
173
183
  system("claude")
174
184
  end
175
185
  end
176
- # Track last resumed path for exit
177
- model.exit_directory = path
178
- rescue => e
186
+ # Track last resumed worktree for exit
187
+ model.resume_to = worktree
188
+ rescue StandardError => e
179
189
  puts "Error: #{e.message}"
180
190
  print "Press any key to return..."
181
191
  STDIN.getc
@@ -184,4 +194,4 @@ module Cwt
184
194
  end
185
195
  end
186
196
  end
187
- 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,201 +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
- SETUP_MARKER = ".cwt_needs_setup"
53
-
54
- def self.add_worktree(name)
55
- # Sanitize name
56
- safe_name = name.strip.gsub(/[^a-zA-Z0-9_\-]/, '_')
57
- path = File.join(WORKTREE_DIR, safe_name)
58
-
59
- # Ensure .worktrees exists
60
- FileUtils.mkdir_p(WORKTREE_DIR)
61
-
62
- # Create worktree
63
- # We create a new branch with the same name as the worktree
64
- cmd = ["git", "worktree", "add", "-b", safe_name, path]
65
- _stdout, stderr, status = Open3.capture3(*cmd)
66
-
67
- unless status.success?
68
- return { success: false, error: stderr }
69
- end
70
-
71
- # Mark worktree as needing setup (will run on first resume)
72
- mark_needs_setup(path)
73
-
74
- { success: true, path: path }
75
- end
76
-
77
- def self.needs_setup?(path)
78
- File.exist?(File.join(path, SETUP_MARKER))
79
- end
80
-
81
- def self.mark_needs_setup(path)
82
- FileUtils.touch(File.join(path, SETUP_MARKER))
83
- end
84
-
85
- def self.mark_setup_complete(path)
86
- marker = File.join(path, SETUP_MARKER)
87
- File.delete(marker) if File.exist?(marker)
88
- end
89
-
90
- def self.run_setup_visible(path)
91
- root = Dir.pwd
92
- setup_script = File.join(root, ".cwt", "setup")
93
-
94
- if File.exist?(setup_script) && File.executable?(setup_script)
95
- puts "\e[1;36m=== Running .cwt/setup ===\e[0m"
96
- puts
97
-
98
- success = Dir.chdir(path) do
99
- system({ "CWT_ROOT" => root }, setup_script)
100
- end
101
-
102
- puts
103
-
104
- unless success
105
- puts "\e[1;33mWarning: .cwt/setup failed (exit code: #{$?.exitstatus})\e[0m"
106
- print "Press Enter to continue or Ctrl+C to abort..."
107
- begin
108
- STDIN.gets
109
- rescue Interrupt
110
- raise
111
- end
112
- end
113
- else
114
- # Default behavior: Symlink .env and node_modules (silent, fast)
115
- setup_default_symlinks(path, root)
116
- end
117
- end
118
-
119
- def self.run_teardown(path)
120
- root = Dir.pwd
121
- teardown_script = File.join(root, ".cwt", "teardown")
122
-
123
- return { ran: false } unless File.exist?(teardown_script) && File.executable?(teardown_script)
124
-
125
- puts "\e[1;36m=== Running .cwt/teardown ===\e[0m"
126
- puts
127
-
128
- success = Dir.chdir(path) do
129
- system({ "CWT_ROOT" => root }, teardown_script)
130
- end
131
-
132
- puts
133
-
134
- { ran: true, success: success }
135
- end
136
-
137
- def self.remove_worktree(path, force: false)
138
- # Step 0: Run teardown script if directory exists
139
- if Dir.exist?(path)
140
- result = run_teardown(path)
141
- if result[:ran] && !result[:success] && !force
142
- return { success: false, error: "Teardown script failed. Use 'D' to force delete." }
143
- end
144
- end
145
-
146
- # Step 1: Cleanup symlinks/copies (Best effort)
147
- # This helps 'safe delete' succeed if only untracked files are present.
148
- [".env", "node_modules"].each do |file|
149
- target_path = File.join(path, file)
150
- if File.exist?(target_path)
151
- File.delete(target_path) rescue nil
152
- end
153
- end
154
-
155
- # Step 2: Remove Worktree
156
- # Only attempt if the directory actually exists.
157
- # This handles the "Phantom Branch" case (worktree gone, branch remains).
158
- if Dir.exist?(path)
159
- wt_cmd = ["git", "--no-optional-locks", "worktree", "remove", path]
160
- wt_cmd << "--force" if force
161
-
162
- stdout, stderr, status = Open3.capture3(*wt_cmd)
163
-
164
- unless status.success?
165
- # If we failed to remove the worktree, we must stop unless it's a "not found" error.
166
- # But "not found" should be covered by Dir.exist? check mostly.
167
- # If git complains about dirty files, we stop here (unless force was used, which is handled by --force).
168
- return { success: false, error: stderr.strip }
169
- end
170
- end
171
-
172
- # Step 3: Delete Branch
173
- # The branch name is usually the basename of the path.
174
- branch_name = File.basename(path)
175
-
176
- branch_flag = force ? "-D" : "-d"
177
- stdout_b, stderr_b, status_b = Open3.capture3("git", "branch", branch_flag, branch_name)
178
-
179
- if status_b.success?
180
- { success: true }
181
- else
182
- # Branch deletion failed.
183
- if force
184
- # Force delete failed. This is weird (maybe branch doesn't exist?).
185
- # If branch doesn't exist, we can consider it success?
186
- if stderr_b.include?("not found")
187
- { success: true }
188
- else
189
- { success: false, error: "Worktree removed, but branch delete failed: #{stderr_b.strip}" }
190
- end
191
- else
192
- # Safe delete failed (unmerged commits).
193
- # This is a valid state: Worktree is gone, but branch remains to save data.
194
- { success: true, warning: "Worktree removed, but branch kept (unmerged). Use 'D' to force." }
195
- end
196
- end
197
- end
198
-
199
- def self.prune_worktrees
200
- Open3.capture2("git", "worktree", "prune")
201
- end
202
-
203
- private
204
-
205
- def self.parse_porcelain(output)
206
- worktrees = []
207
- current = {}
208
-
209
- output.each_line do |line|
210
- if line.start_with?("worktree ")
211
- if current.any?
212
- worktrees << current
213
- current = {}
214
- end
215
- current[:path] = line.sub("worktree ", "").strip
216
- elsif line.start_with?("HEAD ")
217
- current[:sha] = line.sub("HEAD ", "").strip
218
- elsif line.start_with?("branch ")
219
- current[:branch] = line.sub("branch ", "").strip.sub("refs/heads/", "")
220
- end
221
- end
222
- worktrees << current if current.any?
223
- worktrees
224
- end
225
-
226
- def self.setup_default_symlinks(target_path, root)
227
- files_to_link = [".env", "node_modules"]
228
-
229
- files_to_link.each do |file|
230
- source = File.join(root, file)
231
- target = File.join(target_path, file)
232
-
233
- if File.exist?(source) && !File.exist?(target)
234
- FileUtils.ln_s(source, target)
235
- end
236
- 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)
237
48
  end
238
49
  end
239
- end
50
+ end
data/lib/cwt/model.rb CHANGED
@@ -1,10 +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
4
- attr_accessor :exit_directory
5
+ attr_reader :repository, :selection_index, :mode, :input_buffer, :message, :running, :fetch_generation, :filter_query
6
+ attr_accessor :resume_to # Worktree object or nil
5
7
 
6
- def initialize
7
- @worktrees = []
8
+ def initialize(repository)
9
+ @repository = repository
10
+ @worktrees_cache = []
8
11
  @selection_index = 0
9
12
  @mode = :normal # :normal, :creating, :filtering
10
13
  @input_buffer = String.new
@@ -12,19 +15,41 @@ module Cwt
12
15
  @message = "Welcome to CWT"
13
16
  @running = true
14
17
  @fetch_generation = 0
15
- @exit_directory = nil
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
16
29
  end
17
30
 
18
31
  def update_worktrees(list)
19
- @worktrees = list
32
+ @worktrees_cache = list
20
33
  clamp_selection
21
34
  end
22
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
+
23
46
  def visible_worktrees
24
47
  if @filter_query.empty?
25
- @worktrees
48
+ @worktrees_cache
26
49
  else
27
- @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
28
53
  end
29
54
  end
30
55
 
@@ -35,7 +60,7 @@ module Cwt
35
60
  def move_selection(delta)
36
61
  list = visible_worktrees
37
62
  return if list.empty?
38
-
63
+
39
64
  new_index = @selection_index + delta
40
65
  if new_index >= 0 && new_index < list.size
41
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.4
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