claude-worktree 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 11c636e0b9ef4712b640d93ea722f6ee4dc35f1b0b59798620273d6e573d2c85
4
+ data.tar.gz: ffb3e87aee70f3df505b5153bde9f602e4d7120071b0bbc34c520a382d4355fe
5
+ SHA512:
6
+ metadata.gz: 1992b75cb17d8a3eb92eeeef6beef2fa5c40fe8fb4b44e1e32c0cdd090ea305533e4416bd433429bb10d041b743fa85a2fd79bfdf58959a697c3e6ff9d4cf660
7
+ data.tar.gz: 6f5e87be5321b8d4d573ca07aa5babb5012748b0708ffff838624515e4e89b00d7bc39bbbaed050397087f29c66b22a3543d0a634abf975d4ad6c434c6e45183
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-01-29
4
+
5
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "claude-worktree" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["hey@bengarcia.dev"](mailto:"hey@bengarcia.dev").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ben Garcia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # Claude Worktree (cwt)
2
+
3
+ There are a million tools for AI coding right now. Some wrap agents in Docker containers, others proxy every shell command you type, and some try to reinvent your entire IDE.
4
+
5
+ `cwt` is a simple tool built on a simple premise: **Git worktrees are the best way to isolate AI coding sessions, but they are annoying to manage manually.**
6
+
7
+ The goal of this tool is to be as unimposing as possible. We don't want to change how you work, we just want to make the "setup" part faster.
8
+
9
+ ## How it works
10
+
11
+ When you use `cwt`, you are just running a TUI (Terminal User Interface) to manage folders.
12
+
13
+ 1. **It's just Git:** Under the hood, we are just creating standard Git worktrees.
14
+ 2. **Native Environment:** When you enter a session, `cwt` suspends itself and launches a native instance of `claude` (or your preferred shell) directly in that directory.
15
+ 3. **Zero Overhead:** We don't wrap the process. We don't intercept your commands. We don't run a background daemon. Your scripts, your aliases, and your workflow remain exactly the same.
16
+
17
+ ## ⚔ Features
18
+
19
+ * **Fast Management:** Create, switch, and delete worktrees instantly.
20
+ * **Safety Net:** cwt checks for unmerged changes before you delete a session, so you don't accidentally lose work.
21
+ * **Auto-Setup:** Symlinks your .env and node_modules out of the box. If you need a more advanced setup, use `.cwt/setup`
22
+
23
+ ## šŸ“¦ Installation
24
+
25
+ ```bash
26
+ gem install claude-worktree
27
+ ```
28
+
29
+ Or via Homebrew Tap:
30
+ ```bash
31
+ brew tap bengarcia/tap
32
+ brew install cwt
33
+ ```
34
+
35
+ ### The Setup Hook
36
+
37
+ By default, `cwt` will:
38
+
39
+ 1. Symlink `.env` from your root to the worktree.
40
+ 2. Symlink `node_modules` from your root to the worktree.
41
+
42
+ If you want to change this behavior (e.g., to run `npm ci` instead of symlinking, or to copy a different config file), simply create an executable script at `.cwt/setup`.
43
+
44
+ ```bash
45
+ mkdir .cwt
46
+ touch .cwt/setup
47
+ chmod +x .cwt/setup
48
+ ```
49
+
50
+ If this file exists, `cwt` will **skip the default symlinks** and execute your script inside the new worktree instead.
51
+
52
+ **Example `.cwt/setup`:**
53
+
54
+ ```bash
55
+ #!/bin/bash
56
+ # Copy .env so we can modify it safely in this session
57
+ cp ../.env .
58
+
59
+ # Install dependencies freshly (cleaner than symlinking)
60
+ npm ci
61
+
62
+ # Print a welcome message
63
+ echo "Ready to rock!"
64
+ ```
65
+
66
+ ## šŸŽ® Usage
67
+
68
+ Run `cwt` in the root of any Git repository.
69
+
70
+ | Key | Action |
71
+ | :--- | :--- |
72
+ | **`n`** | **New Session** (Creates worktree & launches `claude`) |
73
+ | **`Enter`** | **Resume** (Suspends TUI, enters worktree) |
74
+ | **`/`** | **Filter** (Search by branch or folder name) |
75
+ | **`d`** | **Safe Delete** (Checks for unmerged changes first) |
76
+ | **`D`** | **Force Delete** (Shift+d - The "I know what I'm doing" option) |
77
+ | **`q`** | **Quit** |
78
+
79
+ ## šŸ—ļø Under the Hood
80
+
81
+ * Built in Ruby using `ratatui-ruby` for the UI.
82
+ * Uses a simple thread pool for git operations so the UI doesn't freeze.
83
+ * Uses `Bundler.with_unbundled_env` to ensure your session runs in a clean environment, not one polluted by this tool's dependencies.
84
+
85
+ ## šŸ¤ Contributing
86
+
87
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bengarcia/claude-worktree.
88
+
89
+ ## License
90
+
91
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
data/exe/cwt ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'cwt/app'
4
+
5
+ Cwt::App.run
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claude
4
+ module Worktree
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'worktree/version'
4
+
5
+ module Claude
6
+ module Worktree
7
+ class Error < StandardError; end
8
+ end
9
+ end
data/lib/cwt/app.rb ADDED
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+ require "thread"
5
+ require_relative "model"
6
+ require_relative "view"
7
+ require_relative "update"
8
+ require_relative "git"
9
+
10
+ module Cwt
11
+ class App
12
+ POOL_SIZE = 4
13
+
14
+ def self.run
15
+ model = Model.new
16
+
17
+ # Initialize Thread Pool
18
+ @worker_queue = Queue.new
19
+ @workers = POOL_SIZE.times.map do
20
+ Thread.new do
21
+ while task = @worker_queue.pop
22
+ # Process task
23
+ begin
24
+ case task[:type]
25
+ when :fetch_status
26
+ 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]
32
+ }
33
+ end
34
+ rescue => e
35
+ # Ignore worker errors
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Initial Load
42
+ Update.refresh_list(model)
43
+
44
+ # Main Event Queue
45
+ main_queue = Queue.new
46
+ start_background_fetch(model, main_queue)
47
+
48
+ RatatuiRuby.run do |tui|
49
+ while model.running
50
+ tui.draw do |frame|
51
+ View.draw(model, tui, frame)
52
+ end
53
+
54
+ event = tui.poll_event(timeout: 0.1)
55
+
56
+ # Process TUI Event
57
+ cmd = nil
58
+ if event.key?
59
+ cmd = Update.handle(model, { type: :key_press, key: event })
60
+ elsif event.resize?
61
+ # Layout auto-handles
62
+ elsif event.none?
63
+ cmd = Update.handle(model, { type: :tick })
64
+ end
65
+
66
+ handle_command(cmd, model, tui, main_queue) if cmd
67
+
68
+ # Process Background Queue
69
+ while !main_queue.empty?
70
+ msg = main_queue.pop(true) rescue nil
71
+ if msg
72
+ Update.handle(model, msg)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ def self.handle_command(cmd, model, tui, main_queue)
80
+ return unless cmd
81
+
82
+ if cmd == :start_background_fetch
83
+ start_background_fetch(model, main_queue)
84
+ return
85
+ end
86
+
87
+ # Cmd is a hash
88
+ case cmd[:type]
89
+ when :quit
90
+ model.quit
91
+ when :create_worktree, :delete_worktree, :refresh_list
92
+ result = Update.handle(model, cmd)
93
+ handle_command(result, model, tui, main_queue)
94
+ when :resume_worktree, :suspend_and_resume
95
+ suspend_tui_and_run(cmd[:path], tui)
96
+ Update.refresh_list(model)
97
+ start_background_fetch(model, main_queue)
98
+ end
99
+ end
100
+
101
+ def self.start_background_fetch(model, main_queue)
102
+ # Increment generation to invalidate old results
103
+ model.increment_generation
104
+ current_gen = model.fetch_generation
105
+
106
+ 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)
110
+ Thread.new do
111
+ shas = worktrees.map { |wt| wt[:sha] }.compact
112
+ ages = Git.get_commit_ages(shas)
113
+
114
+ 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
121
+ }
122
+ end
123
+ end
124
+ end
125
+
126
+ # Queue Status Checks (Worker Pool)
127
+ worktrees.each do |wt|
128
+ @worker_queue << {
129
+ type: :fetch_status,
130
+ path: wt[:path],
131
+ result_queue: main_queue,
132
+ generation: current_gen
133
+ }
134
+ end
135
+ end
136
+
137
+ def self.suspend_tui_and_run(path, tui)
138
+ RatatuiRuby.restore_terminal
139
+
140
+ puts "\e[H\e[2J" # Clear screen
141
+ puts "Resuming session in #{path}..."
142
+ begin
143
+ Dir.chdir(path) do
144
+ if defined?(Bundler)
145
+ Bundler.with_unbundled_env { system("claude") }
146
+ else
147
+ system("claude")
148
+ end
149
+ end
150
+ rescue => e
151
+ puts "Error: #{e.message}"
152
+ print "Press any key to return..."
153
+ STDIN.getc
154
+ ensure
155
+ RatatuiRuby.init_terminal
156
+ end
157
+ end
158
+ end
159
+ end
data/lib/cwt/git.rb ADDED
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module Cwt
7
+ 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)
19
+ return {} if shas.empty?
20
+
21
+ # Batch fetch commit times
22
+ # %H: full hash, %cr: relative date
23
+ cmd = ["git", "--no-optional-locks", "show", "-s", "--format=%H|%cr"] + shas
24
+ stdout, status = Open3.capture2(*cmd)
25
+ return {} unless status.success?
26
+
27
+ ages = {}
28
+ stdout.each_line do |line|
29
+ parts = line.strip.split('|')
30
+ if parts.size == 2
31
+ ages[parts[0]] = parts[1]
32
+ end
33
+ end
34
+ ages
35
+ end
36
+
37
+ def self.get_status(path)
38
+ # Check for uncommitted changes
39
+ # --no-optional-locks: Prevent git from writing to the index (lock contention)
40
+ # -C path: Run git in that directory
41
+ # --porcelain: stable output
42
+
43
+ dirty_cmd = ["git", "--no-optional-locks", "-C", path, "status", "--porcelain"]
44
+ stdout_dirty, status_dirty = Open3.capture2(*dirty_cmd)
45
+ is_dirty = status_dirty.success? && !stdout_dirty.strip.empty?
46
+
47
+ { dirty: is_dirty }
48
+ rescue => e
49
+ { dirty: false }
50
+ end
51
+
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
179
+ end
180
+ end
181
+ end
data/lib/cwt/model.rb ADDED
@@ -0,0 +1,102 @@
1
+ module Cwt
2
+ class Model
3
+ attr_reader :worktrees, :selection_index, :mode, :input_buffer, :message, :running, :fetch_generation, :filter_query
4
+
5
+ def initialize
6
+ @worktrees = []
7
+ @selection_index = 0
8
+ @mode = :normal # :normal, :creating, :filtering
9
+ @input_buffer = String.new
10
+ @filter_query = String.new
11
+ @message = "Welcome to CWT"
12
+ @running = true
13
+ @fetch_generation = 0
14
+ end
15
+
16
+ def update_worktrees(list)
17
+ @worktrees = list
18
+ clamp_selection
19
+ end
20
+
21
+ def visible_worktrees
22
+ if @filter_query.empty?
23
+ @worktrees
24
+ else
25
+ @worktrees.select { |wt| wt[:path].include?(@filter_query) || (wt[:branch] && wt[:branch].include?(@filter_query)) }
26
+ end
27
+ end
28
+
29
+ def increment_generation
30
+ @fetch_generation += 1
31
+ end
32
+
33
+ def move_selection(delta)
34
+ list = visible_worktrees
35
+ return if list.empty?
36
+
37
+ new_index = @selection_index + delta
38
+ if new_index >= 0 && new_index < list.size
39
+ @selection_index = new_index
40
+ end
41
+ end
42
+
43
+ def set_mode(mode)
44
+ @mode = mode
45
+ if mode == :creating
46
+ @input_buffer = String.new
47
+ @message = "Enter session name: "
48
+ elsif mode == :filtering
49
+ @message = "Filter: "
50
+ # We don't clear filter query here, we assume user wants to edit it
51
+ else
52
+ @message = "Ready"
53
+ end
54
+ end
55
+
56
+ def set_filter(query)
57
+ @filter_query = query
58
+ @selection_index = 0 # Reset selection on filter change
59
+ end
60
+
61
+ def input_append(char)
62
+ if @mode == :filtering
63
+ @filter_query << char
64
+ @selection_index = 0
65
+ else
66
+ @input_buffer << char
67
+ end
68
+ end
69
+
70
+ def input_backspace
71
+ if @mode == :filtering
72
+ @filter_query.chop!
73
+ @selection_index = 0
74
+ else
75
+ @input_buffer.chop!
76
+ end
77
+ end
78
+
79
+ def set_message(msg)
80
+ @message = msg
81
+ end
82
+
83
+ def selected_worktree
84
+ visible_worktrees[@selection_index]
85
+ end
86
+
87
+ def quit
88
+ @running = false
89
+ end
90
+
91
+ private
92
+
93
+ def clamp_selection
94
+ list = visible_worktrees
95
+ if list.empty?
96
+ @selection_index = 0
97
+ elsif @selection_index >= list.size
98
+ @selection_index = list.size - 1
99
+ end
100
+ end
101
+ end
102
+ end
data/lib/cwt/update.rb ADDED
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git"
4
+
5
+ module Cwt
6
+ class Update
7
+ def self.handle(model, message)
8
+ case message[:type]
9
+ when :tick
10
+ nil
11
+ when :quit
12
+ model.quit
13
+ when :key_press
14
+ handle_key(model, message[:key])
15
+ when :refresh_list
16
+ refresh_list(model)
17
+ :start_background_fetch
18
+ when :create_worktree
19
+ result = Git.add_worktree(message[:name])
20
+ if result[:success]
21
+ model.set_message("Created worktree: #{message[:name]}")
22
+ refresh_list(model)
23
+ model.set_mode(:normal)
24
+ model.set_filter(String.new) # Clear filter
25
+ # 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] }
29
+ else
30
+ model.set_message("Error: #{result[:error]}")
31
+ nil
32
+ end
33
+ when :delete_worktree
34
+ path = message[:path]
35
+ force = message[:force] || false
36
+
37
+ result = Git.remove_worktree(path, force: force)
38
+
39
+ if result[:success]
40
+ if result[:warning]
41
+ model.set_message("Warning: #{result[:warning]}. Use 'D' to force delete.")
42
+ else
43
+ model.set_message("Deleted worktree")
44
+ end
45
+ refresh_list(model)
46
+ :start_background_fetch
47
+ else
48
+ model.set_message("Error deleting: #{result[:error]}. Use 'D' to force delete.")
49
+ nil
50
+ end
51
+ when :resume_worktree
52
+ { type: :suspend_and_resume, path: message[:path] }
53
+ when :update_status
54
+ 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
60
+ nil
61
+ when :update_commit_age
62
+ return nil if message[:generation] != model.fetch_generation
63
+
64
+ target = model.worktrees.find { |wt| wt[:path] == message[:path] }
65
+ if target
66
+ target[:last_commit] = message[:age]
67
+ end
68
+ nil
69
+ end
70
+ end
71
+
72
+ def self.handle_key(model, event)
73
+ if model.mode == :creating
74
+ if event.enter?
75
+ return { type: :create_worktree, name: model.input_buffer }
76
+ elsif event.esc?
77
+ model.set_mode(:normal)
78
+ elsif event.backspace?
79
+ model.input_backspace
80
+ elsif event.to_s.length == 1
81
+ model.input_append(event.to_s)
82
+ end
83
+ elsif model.mode == :filtering
84
+ if event.enter?
85
+ # Select current item and resume
86
+ wt = model.selected_worktree
87
+ if wt
88
+ path = wt[:path]
89
+ model.set_filter(String.new) # Clear filter
90
+ model.set_mode(:normal) # Exit filter mode on selection
91
+ return { type: :resume_worktree, path: path }
92
+ else
93
+ model.set_mode(:normal)
94
+ end
95
+ elsif event.esc?
96
+ model.set_filter(String.new) # Clear filter
97
+ model.set_mode(:normal)
98
+ elsif event.backspace?
99
+ model.input_backspace
100
+ elsif event.down? || event.ctrl_n?
101
+ model.move_selection(1)
102
+ elsif event.up? || event.ctrl_p?
103
+ model.move_selection(-1)
104
+ elsif event.to_s.length == 1
105
+ model.input_append(event.to_s)
106
+ end
107
+ else
108
+ # Normal Mode
109
+ if event.q? || event.ctrl_c?
110
+ return { type: :quit }
111
+ elsif event.j? || event.down?
112
+ model.move_selection(1)
113
+ elsif event.k? || event.up?
114
+ model.move_selection(-1)
115
+ elsif event.n?
116
+ model.set_mode(:creating)
117
+ elsif event.slash? # / key
118
+ model.set_mode(:filtering)
119
+ elsif event.d?
120
+ wt = model.selected_worktree
121
+ return { type: :delete_worktree, path: wt[:path], force: false } if wt
122
+ elsif event.D? # Shift+d
123
+ wt = model.selected_worktree
124
+ return { type: :delete_worktree, path: wt[:path], force: true } if wt
125
+ elsif event.enter?
126
+ wt = model.selected_worktree
127
+ if wt
128
+ path = wt[:path]
129
+ model.set_filter(String.new) # Clear filter on resume
130
+ return { type: :resume_worktree, path: path }
131
+ end
132
+ elsif event.r?
133
+ return { type: :refresh_list }
134
+ end
135
+ end
136
+ nil
137
+ end
138
+
139
+ def self.refresh_list(model)
140
+ list = Git.list_worktrees
141
+ model.update_worktrees(list)
142
+ end
143
+ end
144
+ end
data/lib/cwt/view.rb ADDED
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cwt
4
+ class View
5
+ THEME = {
6
+ header: { fg: :blue, modifiers: [:bold] },
7
+ border: { fg: :dark_gray },
8
+ selection: { fg: :black, bg: :blue },
9
+ text: { fg: :white },
10
+ dim: { fg: :dark_gray },
11
+ accent: { fg: :cyan },
12
+ dirty: { fg: :yellow },
13
+ clean: { fg: :green },
14
+ modal_border: { fg: :magenta }
15
+ }.freeze
16
+
17
+ 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
+ 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
28
+ app_area = centered_app_area(tui, frame.area, width: 100, height: content_height)
29
+
30
+ main_area, footer_area = tui.layout_split(
31
+ app_area,
32
+ direction: :vertical,
33
+ constraints: [
34
+ tui.constraint_fill(1),
35
+ tui.constraint_length(3)
36
+ ]
37
+ )
38
+
39
+ header_area, list_area = tui.layout_split(
40
+ main_area,
41
+ direction: :vertical,
42
+ constraints: [
43
+ tui.constraint_length(3),
44
+ tui.constraint_fill(1)
45
+ ]
46
+ )
47
+
48
+ draw_header(tui, frame, header_area)
49
+ draw_list(model, tui, frame, list_area)
50
+ draw_footer(model, tui, frame, footer_area)
51
+
52
+ if model.mode == :creating
53
+ draw_input_modal(model, tui, frame)
54
+ end
55
+ end
56
+
57
+ 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)
70
+ end
71
+
72
+ def self.draw_header(tui, frame, area)
73
+ # Modern, minimalist header
74
+ title = tui.paragraph(
75
+ text: " CWT • WORKTREE MANAGER ",
76
+ alignment: :center,
77
+ style: tui.style(**THEME[:header]),
78
+ block: tui.block(
79
+ borders: [:bottom],
80
+ border_style: tui.style(**THEME[:border])
81
+ )
82
+ )
83
+ frame.render_widget(title, area)
84
+ end
85
+
86
+ def self.draw_list(model, tui, frame, area)
87
+ items = model.visible_worktrees.map do |wt|
88
+ name = File.basename(wt[:path])
89
+ branch = wt[:branch] || "HEAD"
90
+
91
+ # 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
+
98
+ # Consistent Column Widths
99
+ # Name: 25, Branch: 25, Time: 15, Status: 2
100
+
101
+ tui.text_line(spans: [
102
+ 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])),
105
+ tui.text_span(content: time.rjust(15), style: tui.style(**THEME[:accent]))
106
+ ])
107
+ end
108
+
109
+ # Dynamic Title based on context
110
+ title_content = if model.mode == :filtering
111
+ 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]))
114
+ ])
115
+ else
116
+ tui.text_line(spans: [tui.text_span(content: " SESSIONS ", style: tui.style(**THEME[:dim]))])
117
+ end
118
+
119
+ list = tui.list(
120
+ items: items,
121
+ selected_index: model.selection_index,
122
+ highlight_style: tui.style(**THEME[:selection]),
123
+ highlight_symbol: "ā–Ž", # Modern block cursor
124
+ block: tui.block(
125
+ titles: [ { content: title_content } ],
126
+ borders: [:all],
127
+ border_style: tui.style(**THEME[:border])
128
+ )
129
+ )
130
+
131
+ frame.render_widget(list, area)
132
+ end
133
+
134
+ def self.draw_footer(model, tui, frame, area)
135
+ keys = []
136
+
137
+ # Helper to build consistent key hints
138
+ add_key = ->(key, desc) {
139
+ keys << tui.text_span(content: " #{key} ", style: tui.style(bg: :dark_gray, fg: :white))
140
+ keys << tui.text_span(content: " #{desc} ", style: tui.style(**THEME[:dim]))
141
+ }
142
+
143
+ case model.mode
144
+ when :creating
145
+ add_key.call("Enter", "Confirm")
146
+ add_key.call("Esc", "Cancel")
147
+ when :filtering
148
+ add_key.call("Type", "Search")
149
+ add_key.call("Enter", "Select")
150
+ add_key.call("Esc", "Reset")
151
+ 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")
157
+ end
158
+
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])
177
+
178
+ text = [
179
+ tui.text_line(spans: [tui.text_span(content: model.message, style: msg_style)]),
180
+ tui.text_line(spans: keys)
181
+ ]
182
+
183
+ footer = tui.paragraph(
184
+ text: text,
185
+ block: tui.block(
186
+ borders: [:top],
187
+ border_style: tui.style(**THEME[:border])
188
+ )
189
+ )
190
+
191
+ frame.render_widget(footer, area)
192
+ end
193
+
194
+ def self.draw_input_modal(model, tui, frame)
195
+ area = center_rect(tui, frame.area, 50, 3)
196
+
197
+ # Create a "shadow" or clear background
198
+ frame.render_widget(tui.clear, area)
199
+
200
+ input = tui.paragraph(
201
+ text: model.input_buffer,
202
+ style: tui.style(fg: :white),
203
+ block: tui.block(
204
+ title: " NEW SESSION ",
205
+ title_style: tui.style(fg: :blue, modifiers: [:bold]),
206
+ borders: [:all],
207
+ border_style: tui.style(**THEME[:modal_border])
208
+ )
209
+ )
210
+
211
+ frame.render_widget(input, area)
212
+ end
213
+
214
+ def self.center_rect(tui, area, width_percent, height_len)
215
+ vert = tui.layout_split(
216
+ area,
217
+ direction: :vertical,
218
+ constraints: [
219
+ tui.constraint_percentage((100 - 10) / 2),
220
+ tui.constraint_length(height_len),
221
+ tui.constraint_min(0)
222
+ ]
223
+ )
224
+
225
+ horiz = tui.layout_split(
226
+ vert[1],
227
+ direction: :horizontal,
228
+ constraints: [
229
+ tui.constraint_percentage((100 - width_percent) / 2),
230
+ tui.constraint_percentage(width_percent),
231
+ tui.constraint_min(0)
232
+ ]
233
+ )
234
+
235
+ horiz[1]
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env ruby
2
+ require 'fileutils'
3
+ require 'open3'
4
+
5
+ # Configuration
6
+ DEMO_DIR = "cwt_demo_repo"
7
+ WORKTREES_DIR = ".worktrees"
8
+
9
+ # Funny session names (The Prototyper's chaos)
10
+ SESSIONS = [
11
+ "feature/add-lasers-to-ui",
12
+ "fix/undefined-is-not-a-function-again",
13
+ "chore/upgrade-everything-yolo",
14
+ "experiment/rewrite-in-assembly",
15
+ "refactor/rename-all-variables-to-emoji",
16
+ "feature/dark-mode-for-logs",
17
+ "bug/why-is-production-down",
18
+ "wip/ai-will-fix-it-eventually",
19
+ ]
20
+
21
+ def run(cmd)
22
+ stdout, stderr, status = Open3.capture3(cmd)
23
+ unless status.success?
24
+ puts "Error running: #{cmd}"
25
+ puts stderr
26
+ exit 1
27
+ end
28
+ stdout
29
+ end
30
+
31
+ puts "šŸš€ Setting up CWT Demo Environment..."
32
+
33
+ # 1. Create and Init Repo
34
+ if Dir.exist?(DEMO_DIR)
35
+ puts "Cleaning up old demo..."
36
+ FileUtils.rm_rf(DEMO_DIR)
37
+ end
38
+ FileUtils.mkdir_p(DEMO_DIR)
39
+ Dir.chdir(DEMO_DIR)
40
+
41
+ puts "šŸ“¦ Initializing git repo..."
42
+ run "git init"
43
+ run "git config user.email 'demo@example.com'"
44
+ run "git config user.name 'Demo User'"
45
+ run "touch README.md"
46
+ run "git add README.md"
47
+ run "git commit -m 'Initial commit'"
48
+
49
+ # 2. Create Worktrees
50
+ puts "🌳 Spawning worktrees..."
51
+ SESSIONS.each do |session|
52
+ # Create branch and worktree
53
+ path = File.join(WORKTREES_DIR, session.gsub('/', '-'))
54
+ run "git worktree add -b #{session} #{path}"
55
+
56
+ # Add some "dirty" state to random sessions
57
+ if rand < 0.4
58
+ puts " - Making #{session} dirty..."
59
+ File.write(File.join(path, "dirty_file.txt"), "This is uncommitted work")
60
+ end
61
+ end
62
+
63
+ # 3. Create a fake .env to show off symlinking
64
+ File.write(".env", "SECRET_KEY=12345\nAPI_HOST=localhost:3000")
65
+ File.write("node_modules", "fake_node_modules") # Just a file for demo
66
+
67
+ puts "\n✨ Demo Ready!"
68
+ puts "To run the demo:"
69
+ puts " cd #{DEMO_DIR}"
70
+ puts " cwt"
@@ -0,0 +1,6 @@
1
+ module Claude
2
+ module Worktree
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: claude-worktree
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ben Garcia
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ratatui_ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Manages git worktrees for Claude Code sessions.
27
+ email:
28
+ - hey@bengarcia.dev
29
+ executables:
30
+ - cwt
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - CODE_OF_CONDUCT.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - Rakefile
39
+ - exe/cwt
40
+ - lib/claude/worktree.rb
41
+ - lib/claude/worktree/version.rb
42
+ - lib/cwt/app.rb
43
+ - lib/cwt/git.rb
44
+ - lib/cwt/model.rb
45
+ - lib/cwt/update.rb
46
+ - lib/cwt/view.rb
47
+ - scripts/setup_demo.rb
48
+ - sig/claude/worktree.rbs
49
+ homepage: https://github.com/bengarcia/claude-worktree
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ allowed_push_host: https://rubygems.org
54
+ homepage_uri: https://github.com/bengarcia/claude-worktree
55
+ source_code_uri: https://github.com/bengarcia/claude-worktree
56
+ changelog_uri: https://github.com/bengarcia/claude-worktree/blob/main/CHANGELOG.md
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.2.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.6.9
72
+ specification_version: 4
73
+ summary: A TUI tool to manage Git Worktrees for AI coding agents.
74
+ test_files: []