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 +4 -4
- data/README.md +5 -3
- data/lib/claude/worktree/version.rb +1 -1
- data/lib/cwt/app.rb +54 -44
- data/lib/cwt/git.rb +16 -205
- data/lib/cwt/model.rb +34 -9
- data/lib/cwt/repository.rb +145 -0
- data/lib/cwt/update.rb +17 -28
- data/lib/cwt/view.rb +56 -85
- data/lib/cwt/worktree.rb +197 -0
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c13372671d9041cb88fbe74d783caec8d15cba40c9c0395b6ab64ef6105913c
|
|
4
|
+
data.tar.gz: dcf190dcc57792301fdddaf01ac7e880b56a14c97ee67f2095283ddc459b53dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
80
|
-
Dir.chdir(model.
|
|
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[:
|
|
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
|
|
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
|
|
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
|
|
129
|
-
main_queue << {
|
|
130
|
-
type: :update_commit_age,
|
|
131
|
-
path: wt
|
|
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
|
|
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(
|
|
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
|
|
166
|
+
if worktree.needs_setup?
|
|
157
167
|
begin
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
177
|
-
model.
|
|
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
|
-
|
|
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"
|
|
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
|
|
39
|
+
rescue StandardError
|
|
49
40
|
{ dirty: false }
|
|
50
41
|
end
|
|
51
42
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 :
|
|
4
|
-
attr_accessor :
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
48
|
+
@worktrees_cache
|
|
26
49
|
else
|
|
27
|
-
@
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
worktree = message[:worktree]
|
|
35
31
|
force = message[:force] || false
|
|
36
|
-
|
|
37
|
-
result =
|
|
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,
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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:
|
|
113
|
-
tui.text_span(content: model.filter_query,
|
|
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:
|
|
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:
|
|
109
|
+
highlight_symbol: '▎',
|
|
124
110
|
block: tui.block(
|
|
125
|
-
titles: [
|
|
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
|
-
|
|
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(
|
|
146
|
-
add_key.call(
|
|
130
|
+
add_key.call('Enter', 'Confirm')
|
|
131
|
+
add_key.call('Esc', 'Cancel')
|
|
147
132
|
when :filtering
|
|
148
|
-
add_key.call(
|
|
149
|
-
add_key.call(
|
|
150
|
-
add_key.call(
|
|
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(
|
|
153
|
-
add_key.call(
|
|
154
|
-
add_key.call(
|
|
155
|
-
add_key.call(
|
|
156
|
-
add_key.call(
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
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
|
data/lib/cwt/worktree.rb
ADDED
|
@@ -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.
|
|
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/
|
|
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/
|
|
53
|
-
source_code_uri: https://github.com/
|
|
54
|
-
changelog_uri: https://github.com/
|
|
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
|