claude-worktree 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +5 -3
- data/lib/claude/worktree/version.rb +1 -1
- data/lib/cwt/app.rb +79 -41
- data/lib/cwt/git.rb +16 -147
- data/lib/cwt/model.rb +34 -7
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.1.4] - 2026-01-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Permanent CD on exit**: After quitting cwt, your shell stays in the last resumed worktree directory
|
|
7
|
+
- **Visible setup output**: `.cwt/setup` script now runs with visible output on first resume (not during worktree creation)
|
|
8
|
+
- **Teardown support**: Optional `.cwt/teardown` script runs before worktree deletion
|
|
9
|
+
- **CWT_ROOT environment variable**: Setup and teardown scripts receive `$CWT_ROOT` pointing to the repo root
|
|
10
|
+
- Integration tests for setup/teardown functionality
|
|
11
|
+
- Homebrew update instructions in deploy script
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Setup now runs on first resume instead of during worktree creation
|
|
15
|
+
- Setup only runs once per worktree (tracked via `.cwt_needs_setup` marker)
|
|
16
|
+
|
|
3
17
|
## [0.1.0] - 2026-01-29
|
|
4
18
|
|
|
5
19
|
- Initial release
|
data/README.md
CHANGED
|
@@ -32,7 +32,7 @@ gem install claude-worktree
|
|
|
32
32
|
|
|
33
33
|
Or via Homebrew Tap:
|
|
34
34
|
```bash
|
|
35
|
-
brew tap
|
|
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)
|
|
@@ -52,7 +63,7 @@ module Cwt
|
|
|
52
63
|
end
|
|
53
64
|
|
|
54
65
|
event = tui.poll_event(timeout: 0.1)
|
|
55
|
-
|
|
66
|
+
|
|
56
67
|
# Process TUI Event
|
|
57
68
|
cmd = nil
|
|
58
69
|
if event.key?
|
|
@@ -62,18 +73,24 @@ module Cwt
|
|
|
62
73
|
elsif event.none?
|
|
63
74
|
cmd = Update.handle(model, { type: :tick })
|
|
64
75
|
end
|
|
65
|
-
|
|
76
|
+
|
|
66
77
|
handle_command(cmd, model, tui, main_queue) if cmd
|
|
67
78
|
|
|
68
79
|
# Process Background Queue
|
|
69
80
|
while !main_queue.empty?
|
|
70
81
|
msg = main_queue.pop(true) rescue nil
|
|
71
|
-
if msg
|
|
72
|
-
Update.handle(model, msg)
|
|
73
|
-
end
|
|
82
|
+
Update.handle(model, msg) if msg
|
|
74
83
|
end
|
|
75
84
|
end
|
|
76
85
|
end
|
|
86
|
+
|
|
87
|
+
# After TUI exits, cd into last worktree if one was resumed
|
|
88
|
+
if model.resume_to && model.resume_to.exists?
|
|
89
|
+
Dir.chdir(model.resume_to.path)
|
|
90
|
+
# OSC 7 tells terminal emulators (Ghostty, tmux, iTerm2) the CWD for new panes
|
|
91
|
+
print "\e]7;file://localhost#{model.resume_to.path}\e\\"
|
|
92
|
+
exec ENV.fetch('SHELL', '/bin/zsh')
|
|
93
|
+
end
|
|
77
94
|
end
|
|
78
95
|
|
|
79
96
|
def self.handle_command(cmd, model, tui, main_queue)
|
|
@@ -88,11 +105,18 @@ module Cwt
|
|
|
88
105
|
case cmd[:type]
|
|
89
106
|
when :quit
|
|
90
107
|
model.quit
|
|
91
|
-
when :
|
|
108
|
+
when :delete_worktree
|
|
109
|
+
# Suspend TUI for visible teardown output
|
|
110
|
+
RatatuiRuby.restore_terminal
|
|
111
|
+
puts "\e[H\e[2J" # Clear screen
|
|
112
|
+
result = Update.handle(model, cmd)
|
|
113
|
+
RatatuiRuby.init_terminal
|
|
114
|
+
handle_command(result, model, tui, main_queue)
|
|
115
|
+
when :create_worktree, :refresh_list
|
|
92
116
|
result = Update.handle(model, cmd)
|
|
93
117
|
handle_command(result, model, tui, main_queue)
|
|
94
118
|
when :resume_worktree, :suspend_and_resume
|
|
95
|
-
suspend_tui_and_run(cmd[:
|
|
119
|
+
suspend_tui_and_run(cmd[:worktree], model, tui)
|
|
96
120
|
Update.refresh_list(model)
|
|
97
121
|
start_background_fetch(model, main_queue)
|
|
98
122
|
end
|
|
@@ -104,20 +128,19 @@ module Cwt
|
|
|
104
128
|
current_gen = model.fetch_generation
|
|
105
129
|
|
|
106
130
|
worktrees = model.worktrees
|
|
107
|
-
|
|
108
|
-
# Batch fetch commit ages
|
|
109
|
-
# Git.get_commit_ages is fast. Let's do it in a one-off thread to not block UI)
|
|
131
|
+
|
|
132
|
+
# Batch fetch commit ages in background thread
|
|
110
133
|
Thread.new do
|
|
111
|
-
shas = worktrees.map
|
|
112
|
-
ages = Git.get_commit_ages(shas)
|
|
113
|
-
|
|
134
|
+
shas = worktrees.map(&:sha).compact
|
|
135
|
+
ages = Git.get_commit_ages(shas, repo_root: model.repository.root)
|
|
136
|
+
|
|
114
137
|
worktrees.each do |wt|
|
|
115
|
-
if age = ages[wt
|
|
116
|
-
main_queue << {
|
|
117
|
-
type: :update_commit_age,
|
|
118
|
-
path: wt
|
|
119
|
-
age: age,
|
|
120
|
-
generation: current_gen
|
|
138
|
+
if (age = ages[wt.sha])
|
|
139
|
+
main_queue << {
|
|
140
|
+
type: :update_commit_age,
|
|
141
|
+
path: wt.path,
|
|
142
|
+
age: age,
|
|
143
|
+
generation: current_gen
|
|
121
144
|
}
|
|
122
145
|
end
|
|
123
146
|
end
|
|
@@ -125,29 +148,44 @@ module Cwt
|
|
|
125
148
|
|
|
126
149
|
# Queue Status Checks (Worker Pool)
|
|
127
150
|
worktrees.each do |wt|
|
|
128
|
-
@worker_queue << {
|
|
129
|
-
type: :fetch_status,
|
|
130
|
-
path: wt
|
|
131
|
-
result_queue: main_queue,
|
|
132
|
-
generation: current_gen
|
|
151
|
+
@worker_queue << {
|
|
152
|
+
type: :fetch_status,
|
|
153
|
+
path: wt.path,
|
|
154
|
+
result_queue: main_queue,
|
|
155
|
+
generation: current_gen
|
|
133
156
|
}
|
|
134
157
|
end
|
|
135
158
|
end
|
|
136
159
|
|
|
137
|
-
def self.suspend_tui_and_run(
|
|
160
|
+
def self.suspend_tui_and_run(worktree, model, tui)
|
|
138
161
|
RatatuiRuby.restore_terminal
|
|
139
|
-
|
|
162
|
+
|
|
140
163
|
puts "\e[H\e[2J" # Clear screen
|
|
141
|
-
|
|
164
|
+
|
|
165
|
+
# Run setup if this is a new worktree
|
|
166
|
+
if worktree.needs_setup?
|
|
167
|
+
begin
|
|
168
|
+
worktree.run_setup!(visible: true)
|
|
169
|
+
worktree.mark_setup_complete!
|
|
170
|
+
rescue Interrupt
|
|
171
|
+
puts "\nSetup aborted."
|
|
172
|
+
RatatuiRuby.init_terminal
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
puts "Launching claude in #{worktree.path}..."
|
|
142
178
|
begin
|
|
143
|
-
Dir.chdir(path) do
|
|
179
|
+
Dir.chdir(worktree.path) do
|
|
144
180
|
if defined?(Bundler)
|
|
145
181
|
Bundler.with_unbundled_env { system("claude") }
|
|
146
182
|
else
|
|
147
183
|
system("claude")
|
|
148
184
|
end
|
|
149
185
|
end
|
|
150
|
-
|
|
186
|
+
# Track last resumed worktree for exit
|
|
187
|
+
model.resume_to = worktree
|
|
188
|
+
rescue StandardError => e
|
|
151
189
|
puts "Error: #{e.message}"
|
|
152
190
|
print "Press any key to return..."
|
|
153
191
|
STDIN.getc
|
|
@@ -156,4 +194,4 @@ module Cwt
|
|
|
156
194
|
end
|
|
157
195
|
end
|
|
158
196
|
end
|
|
159
|
-
end
|
|
197
|
+
end
|
data/lib/cwt/git.rb
CHANGED
|
@@ -1,35 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
|
-
require 'fileutils'
|
|
5
4
|
|
|
6
5
|
module Cwt
|
|
6
|
+
# Thin wrapper around git commands.
|
|
7
|
+
# Business logic lives in Repository and Worktree classes.
|
|
7
8
|
class Git
|
|
8
|
-
|
|
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,143 +31,20 @@ module Cwt
|
|
|
39
31
|
# --no-optional-locks: Prevent git from writing to the index (lock contention)
|
|
40
32
|
# -C path: Run git in that directory
|
|
41
33
|
# --porcelain: stable output
|
|
42
|
-
|
|
43
34
|
dirty_cmd = ["git", "--no-optional-locks", "-C", path, "status", "--porcelain"]
|
|
44
35
|
stdout_dirty, status_dirty = Open3.capture2(*dirty_cmd)
|
|
45
36
|
is_dirty = status_dirty.success? && !stdout_dirty.strip.empty?
|
|
46
37
|
|
|
47
38
|
{ dirty: is_dirty }
|
|
48
|
-
rescue
|
|
39
|
+
rescue StandardError
|
|
49
40
|
{ dirty: false }
|
|
50
41
|
end
|
|
51
42
|
|
|
52
|
-
def self.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# Ensure .worktrees exists
|
|
58
|
-
FileUtils.mkdir_p(WORKTREE_DIR)
|
|
59
|
-
|
|
60
|
-
# Create worktree
|
|
61
|
-
# We create a new branch with the same name as the worktree
|
|
62
|
-
cmd = ["git", "worktree", "add", "-b", safe_name, path]
|
|
63
|
-
_stdout, stderr, status = Open3.capture3(*cmd)
|
|
64
|
-
|
|
65
|
-
unless status.success?
|
|
66
|
-
return { success: false, error: stderr }
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Post-creation setup: Copy .env if it exists
|
|
70
|
-
setup_environment(path)
|
|
71
|
-
|
|
72
|
-
{ success: true, path: path }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def self.remove_worktree(path, force: false)
|
|
76
|
-
# Step 1: Cleanup symlinks/copies (Best effort)
|
|
77
|
-
# This helps 'safe delete' succeed if only untracked files are present.
|
|
78
|
-
[".env", "node_modules"].each do |file|
|
|
79
|
-
target_path = File.join(path, file)
|
|
80
|
-
if File.exist?(target_path)
|
|
81
|
-
File.delete(target_path) rescue nil
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Step 2: Remove Worktree
|
|
86
|
-
# Only attempt if the directory actually exists.
|
|
87
|
-
# This handles the "Phantom Branch" case (worktree gone, branch remains).
|
|
88
|
-
if Dir.exist?(path)
|
|
89
|
-
wt_cmd = ["git", "--no-optional-locks", "worktree", "remove", path]
|
|
90
|
-
wt_cmd << "--force" if force
|
|
91
|
-
|
|
92
|
-
stdout, stderr, status = Open3.capture3(*wt_cmd)
|
|
93
|
-
|
|
94
|
-
unless status.success?
|
|
95
|
-
# If we failed to remove the worktree, we must stop unless it's a "not found" error.
|
|
96
|
-
# But "not found" should be covered by Dir.exist? check mostly.
|
|
97
|
-
# If git complains about dirty files, we stop here (unless force was used, which is handled by --force).
|
|
98
|
-
return { success: false, error: stderr.strip }
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Step 3: Delete Branch
|
|
103
|
-
# The branch name is usually the basename of the path.
|
|
104
|
-
branch_name = File.basename(path)
|
|
105
|
-
|
|
106
|
-
branch_flag = force ? "-D" : "-d"
|
|
107
|
-
stdout_b, stderr_b, status_b = Open3.capture3("git", "branch", branch_flag, branch_name)
|
|
108
|
-
|
|
109
|
-
if status_b.success?
|
|
110
|
-
{ success: true }
|
|
111
|
-
else
|
|
112
|
-
# Branch deletion failed.
|
|
113
|
-
if force
|
|
114
|
-
# Force delete failed. This is weird (maybe branch doesn't exist?).
|
|
115
|
-
# If branch doesn't exist, we can consider it success?
|
|
116
|
-
if stderr_b.include?("not found")
|
|
117
|
-
{ success: true }
|
|
118
|
-
else
|
|
119
|
-
{ success: false, error: "Worktree removed, but branch delete failed: #{stderr_b.strip}" }
|
|
120
|
-
end
|
|
121
|
-
else
|
|
122
|
-
# Safe delete failed (unmerged commits).
|
|
123
|
-
# This is a valid state: Worktree is gone, but branch remains to save data.
|
|
124
|
-
{ success: true, warning: "Worktree removed, but branch kept (unmerged). Use 'D' to force." }
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def self.prune_worktrees
|
|
130
|
-
Open3.capture2("git", "worktree", "prune")
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
private
|
|
134
|
-
|
|
135
|
-
def self.parse_porcelain(output)
|
|
136
|
-
worktrees = []
|
|
137
|
-
current = {}
|
|
138
|
-
|
|
139
|
-
output.each_line do |line|
|
|
140
|
-
if line.start_with?("worktree ")
|
|
141
|
-
if current.any?
|
|
142
|
-
worktrees << current
|
|
143
|
-
current = {}
|
|
144
|
-
end
|
|
145
|
-
current[:path] = line.sub("worktree ", "").strip
|
|
146
|
-
elsif line.start_with?("HEAD ")
|
|
147
|
-
current[:sha] = line.sub("HEAD ", "").strip
|
|
148
|
-
elsif line.start_with?("branch ")
|
|
149
|
-
current[:branch] = line.sub("branch ", "").strip.sub("refs/heads/", "")
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
worktrees << current if current.any?
|
|
153
|
-
worktrees
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def self.setup_environment(target_path)
|
|
157
|
-
root = Dir.pwd
|
|
158
|
-
setup_script = File.join(root, ".cwt", "setup")
|
|
159
|
-
|
|
160
|
-
# 1. Custom Setup Script
|
|
161
|
-
if File.exist?(setup_script) && File.executable?(setup_script)
|
|
162
|
-
# Execute the script inside the new worktree
|
|
163
|
-
# passing the root path as an argument might be helpful, but relying on relative paths is standard.
|
|
164
|
-
Open3.capture2(setup_script, chdir: target_path)
|
|
165
|
-
return
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# 2. Default Behavior: Symlink .env and node_modules
|
|
169
|
-
files_to_link = [".env", "node_modules"]
|
|
170
|
-
|
|
171
|
-
files_to_link.each do |file|
|
|
172
|
-
source = File.join(root, file)
|
|
173
|
-
target = File.join(target_path, file)
|
|
174
|
-
|
|
175
|
-
if File.exist?(source) && !File.exist?(target)
|
|
176
|
-
FileUtils.ln_s(source, target)
|
|
177
|
-
end
|
|
178
|
-
end
|
|
43
|
+
def self.prune_worktrees(repo_root: nil)
|
|
44
|
+
cmd = ["git"]
|
|
45
|
+
cmd += ["-C", repo_root] if repo_root
|
|
46
|
+
cmd << "worktree" << "prune"
|
|
47
|
+
Open3.capture2(*cmd)
|
|
179
48
|
end
|
|
180
49
|
end
|
|
181
|
-
end
|
|
50
|
+
end
|
data/lib/cwt/model.rb
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Cwt
|
|
2
4
|
class Model
|
|
3
|
-
attr_reader :
|
|
5
|
+
attr_reader :repository, :selection_index, :mode, :input_buffer, :message, :running, :fetch_generation, :filter_query
|
|
6
|
+
attr_accessor :resume_to # Worktree object or nil
|
|
4
7
|
|
|
5
|
-
def initialize
|
|
6
|
-
@
|
|
8
|
+
def initialize(repository)
|
|
9
|
+
@repository = repository
|
|
10
|
+
@worktrees_cache = []
|
|
7
11
|
@selection_index = 0
|
|
8
12
|
@mode = :normal # :normal, :creating, :filtering
|
|
9
13
|
@input_buffer = String.new
|
|
@@ -11,18 +15,41 @@ module Cwt
|
|
|
11
15
|
@message = "Welcome to CWT"
|
|
12
16
|
@running = true
|
|
13
17
|
@fetch_generation = 0
|
|
18
|
+
@resume_to = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def worktrees
|
|
22
|
+
@worktrees_cache
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def refresh_worktrees!
|
|
26
|
+
@worktrees_cache = @repository.worktrees
|
|
27
|
+
clamp_selection
|
|
28
|
+
@worktrees_cache
|
|
14
29
|
end
|
|
15
30
|
|
|
16
31
|
def update_worktrees(list)
|
|
17
|
-
@
|
|
32
|
+
@worktrees_cache = list
|
|
18
33
|
clamp_selection
|
|
19
34
|
end
|
|
20
35
|
|
|
36
|
+
def find_worktree_by_path(path)
|
|
37
|
+
# Normalize path for comparison (handles macOS /var -> /private/var symlinks)
|
|
38
|
+
normalized = begin
|
|
39
|
+
File.realpath(path)
|
|
40
|
+
rescue Errno::ENOENT
|
|
41
|
+
File.expand_path(path)
|
|
42
|
+
end
|
|
43
|
+
@worktrees_cache.find { |wt| wt.path == normalized }
|
|
44
|
+
end
|
|
45
|
+
|
|
21
46
|
def visible_worktrees
|
|
22
47
|
if @filter_query.empty?
|
|
23
|
-
@
|
|
48
|
+
@worktrees_cache
|
|
24
49
|
else
|
|
25
|
-
@
|
|
50
|
+
@worktrees_cache.select do |wt|
|
|
51
|
+
wt.path.include?(@filter_query) || (wt.branch && wt.branch.include?(@filter_query))
|
|
52
|
+
end
|
|
26
53
|
end
|
|
27
54
|
end
|
|
28
55
|
|
|
@@ -33,7 +60,7 @@ module Cwt
|
|
|
33
60
|
def move_selection(delta)
|
|
34
61
|
list = visible_worktrees
|
|
35
62
|
return if list.empty?
|
|
36
|
-
|
|
63
|
+
|
|
37
64
|
new_index = @selection_index + delta
|
|
38
65
|
if new_index >= 0 && new_index < list.size
|
|
39
66
|
@selection_index = new_index
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Cwt
|
|
7
|
+
class Repository
|
|
8
|
+
WORKTREE_DIR = ".worktrees"
|
|
9
|
+
CONFIG_DIR = ".cwt"
|
|
10
|
+
|
|
11
|
+
attr_reader :root
|
|
12
|
+
|
|
13
|
+
# Find repo root from any path (including from within worktrees)
|
|
14
|
+
def self.discover(start_path = Dir.pwd)
|
|
15
|
+
Dir.chdir(start_path) do
|
|
16
|
+
stdout, status = Open3.capture2("git", "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
17
|
+
return nil unless status.success?
|
|
18
|
+
|
|
19
|
+
git_common_dir = stdout.strip
|
|
20
|
+
return nil if git_common_dir.empty?
|
|
21
|
+
|
|
22
|
+
# --git-common-dir returns /path/to/repo/.git, so strip the /.git
|
|
23
|
+
new(git_common_dir.sub(%r{/\.git$}, ''))
|
|
24
|
+
end
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(root)
|
|
30
|
+
@root = File.expand_path(root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def worktrees_dir
|
|
34
|
+
File.join(@root, WORKTREE_DIR)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def config_dir
|
|
38
|
+
File.join(@root, CONFIG_DIR)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def setup_script_path
|
|
42
|
+
File.join(config_dir, "setup")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def teardown_script_path
|
|
46
|
+
File.join(config_dir, "teardown")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def has_setup_script?
|
|
50
|
+
File.exist?(setup_script_path) && File.executable?(setup_script_path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def has_teardown_script?
|
|
54
|
+
File.exist?(teardown_script_path) && File.executable?(teardown_script_path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns Array<Worktree>
|
|
58
|
+
def worktrees
|
|
59
|
+
require_relative 'worktree'
|
|
60
|
+
|
|
61
|
+
stdout, status = Open3.capture2("git", "-C", @root, "worktree", "list", "--porcelain")
|
|
62
|
+
return [] unless status.success?
|
|
63
|
+
|
|
64
|
+
parse_porcelain(stdout).map do |data|
|
|
65
|
+
Worktree.new(
|
|
66
|
+
repository: self,
|
|
67
|
+
path: data[:path],
|
|
68
|
+
branch: data[:branch],
|
|
69
|
+
sha: data[:sha]
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def find_worktree(name_or_path)
|
|
75
|
+
# Normalize path for comparison (handles macOS /var -> /private/var symlinks)
|
|
76
|
+
normalized_path = begin
|
|
77
|
+
File.realpath(name_or_path)
|
|
78
|
+
rescue Errno::ENOENT
|
|
79
|
+
File.expand_path(name_or_path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
worktrees.find do |wt|
|
|
83
|
+
wt.name == name_or_path || wt.path == normalized_path
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Create a new worktree with the given name
|
|
88
|
+
# Returns { success: true, worktree: Worktree } or { success: false, error: String }
|
|
89
|
+
def create_worktree(name)
|
|
90
|
+
require_relative 'worktree'
|
|
91
|
+
|
|
92
|
+
# Sanitize name
|
|
93
|
+
safe_name = name.strip.gsub(/[^a-zA-Z0-9_\-]/, '_')
|
|
94
|
+
path = File.join(worktrees_dir, safe_name)
|
|
95
|
+
absolute_path = File.join(@root, WORKTREE_DIR, safe_name)
|
|
96
|
+
|
|
97
|
+
# Ensure .worktrees exists
|
|
98
|
+
FileUtils.mkdir_p(worktrees_dir)
|
|
99
|
+
|
|
100
|
+
# Create worktree with new branch
|
|
101
|
+
cmd = ["git", "-C", @root, "worktree", "add", "-b", safe_name, path]
|
|
102
|
+
_stdout, stderr, status = Open3.capture3(*cmd)
|
|
103
|
+
|
|
104
|
+
unless status.success?
|
|
105
|
+
return { success: false, error: stderr }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Create worktree object
|
|
109
|
+
worktree = Worktree.new(
|
|
110
|
+
repository: self,
|
|
111
|
+
path: absolute_path,
|
|
112
|
+
branch: safe_name,
|
|
113
|
+
sha: nil # Will be populated on next list
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Mark as needing setup
|
|
117
|
+
worktree.mark_needs_setup!
|
|
118
|
+
|
|
119
|
+
{ success: true, worktree: worktree }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def parse_porcelain(output)
|
|
125
|
+
worktrees = []
|
|
126
|
+
current = {}
|
|
127
|
+
|
|
128
|
+
output.each_line do |line|
|
|
129
|
+
if line.start_with?("worktree ")
|
|
130
|
+
if current.any?
|
|
131
|
+
worktrees << current
|
|
132
|
+
current = {}
|
|
133
|
+
end
|
|
134
|
+
current[:path] = line.sub("worktree ", "").strip
|
|
135
|
+
elsif line.start_with?("HEAD ")
|
|
136
|
+
current[:sha] = line.sub("HEAD ", "").strip
|
|
137
|
+
elsif line.start_with?("branch ")
|
|
138
|
+
current[:branch] = line.sub("branch ", "").strip.sub("refs/heads/", "")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
worktrees << current if current.any?
|
|
142
|
+
worktrees
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/cwt/update.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "git"
|
|
4
|
-
|
|
5
3
|
module Cwt
|
|
6
4
|
class Update
|
|
7
5
|
def self.handle(model, message)
|
|
@@ -16,26 +14,24 @@ module Cwt
|
|
|
16
14
|
refresh_list(model)
|
|
17
15
|
:start_background_fetch
|
|
18
16
|
when :create_worktree
|
|
19
|
-
result =
|
|
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
|