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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +91 -0
- data/Rakefile +8 -0
- data/exe/cwt +5 -0
- data/lib/claude/worktree/version.rb +7 -0
- data/lib/claude/worktree.rb +9 -0
- data/lib/cwt/app.rb +159 -0
- data/lib/cwt/git.rb +181 -0
- data/lib/cwt/model.rb +102 -0
- data/lib/cwt/update.rb +144 -0
- data/lib/cwt/view.rb +238 -0
- data/scripts/setup_demo.rb +70 -0
- data/sig/claude/worktree.rbs +6 -0
- metadata +74 -0
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
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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
data/exe/cwt
ADDED
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"
|
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: []
|