worktrees 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/.claude/commands/plan.md +42 -0
- data/.claude/commands/specify.md +12 -0
- data/.claude/commands/tasks.md +60 -0
- data/.cursor/commands/plan.md +42 -0
- data/.cursor/commands/specify.md +12 -0
- data/.cursor/commands/tasks.md +60 -0
- data/.cursor/rules +81 -0
- data/.specify/memory/constitution-v1.0.1-formal.md +90 -0
- data/.specify/memory/constitution.md +153 -0
- data/.specify/memory/constitution_update_checklist.md +88 -0
- data/.specify/scripts/bash/check-task-prerequisites.sh +15 -0
- data/.specify/scripts/bash/common.sh +37 -0
- data/.specify/scripts/bash/create-new-feature.sh +58 -0
- data/.specify/scripts/bash/get-feature-paths.sh +7 -0
- data/.specify/scripts/bash/setup-plan.sh +17 -0
- data/.specify/scripts/bash/update-agent-context.sh +57 -0
- data/.specify/templates/agent-file-template.md +23 -0
- data/.specify/templates/plan-template.md +254 -0
- data/.specify/templates/spec-template.md +116 -0
- data/.specify/templates/tasks-template.md +152 -0
- data/CLAUDE.md +145 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +150 -0
- data/README.md +163 -0
- data/Rakefile +52 -0
- data/exe/worktrees +52 -0
- data/lib/worktrees/cli.rb +36 -0
- data/lib/worktrees/commands/create.rb +74 -0
- data/lib/worktrees/commands/list.rb +87 -0
- data/lib/worktrees/commands/remove.rb +62 -0
- data/lib/worktrees/commands/status.rb +95 -0
- data/lib/worktrees/commands/switch.rb +57 -0
- data/lib/worktrees/git_operations.rb +106 -0
- data/lib/worktrees/models/feature_worktree.rb +92 -0
- data/lib/worktrees/models/repository.rb +75 -0
- data/lib/worktrees/models/worktree_config.rb +74 -0
- data/lib/worktrees/version.rb +5 -0
- data/lib/worktrees/worktree_manager.rb +238 -0
- data/lib/worktrees.rb +27 -0
- data/specs/001-build-a-tool/GEMINI.md +20 -0
- data/specs/001-build-a-tool/contracts/cli-contracts.md +43 -0
- data/specs/001-build-a-tool/contracts/openapi.yaml +135 -0
- data/specs/001-build-a-tool/data-model.md +51 -0
- data/specs/001-build-a-tool/plan.md +241 -0
- data/specs/001-build-a-tool/quickstart.md +67 -0
- data/specs/001-build-a-tool/research.md +76 -0
- data/specs/001-build-a-tool/spec.md +153 -0
- data/specs/001-build-a-tool/tasks.md +209 -0
- metadata +138 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Worktrees
|
|
4
|
+
module Models
|
|
5
|
+
class Repository
|
|
6
|
+
attr_reader :root_path
|
|
7
|
+
|
|
8
|
+
def initialize(root_path)
|
|
9
|
+
@root_path = File.expand_path(root_path)
|
|
10
|
+
validate_git_repository!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def default_branch
|
|
14
|
+
git_default_branch
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def branch_exists?(branch_name)
|
|
18
|
+
git_branch_exists?(branch_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def remote_url
|
|
22
|
+
git_remote_url
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def worktrees_path
|
|
26
|
+
config.expand_worktrees_root
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def config
|
|
30
|
+
@config ||= WorktreeConfig.load
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_git_repository!
|
|
36
|
+
git_dir = File.join(@root_path, '.git')
|
|
37
|
+
# .git can be either a directory (main repo) or a file (worktree)
|
|
38
|
+
unless File.exist?(git_dir)
|
|
39
|
+
raise GitError, "Not a git repository: #{@root_path}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def git_default_branch
|
|
44
|
+
# Try to get default branch from remote HEAD
|
|
45
|
+
result = `cd "#{@root_path}" && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null`.strip
|
|
46
|
+
if $?.success? && !result.empty?
|
|
47
|
+
return result.split('/').last
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fallback: check if main exists, then master
|
|
51
|
+
if git_branch_exists?('main')
|
|
52
|
+
'main'
|
|
53
|
+
elsif git_branch_exists?('master')
|
|
54
|
+
'master'
|
|
55
|
+
else
|
|
56
|
+
# Use current branch as last resort
|
|
57
|
+
git_current_branch
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def git_branch_exists?(branch_name)
|
|
62
|
+
system('git', 'show-ref', '--verify', '--quiet', "refs/heads/#{branch_name}", chdir: @root_path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def git_current_branch
|
|
66
|
+
`cd "#{@root_path}" && git rev-parse --abbrev-ref HEAD`.strip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def git_remote_url
|
|
70
|
+
result = `cd "#{@root_path}" && git remote get-url origin 2>/dev/null`.strip
|
|
71
|
+
$?.success? && !result.empty? ? result : nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module Worktrees
|
|
7
|
+
module Models
|
|
8
|
+
class WorktreeConfig
|
|
9
|
+
DEFAULT_ROOT = '~/.worktrees'
|
|
10
|
+
NAME_PATTERN = /^[0-9]{3}-[a-z0-9-]{1,40}$/.freeze
|
|
11
|
+
RESERVED_NAMES = %w[main master].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :worktrees_root, :default_base, :force_cleanup, :name_pattern
|
|
14
|
+
|
|
15
|
+
def initialize(worktrees_root: DEFAULT_ROOT, default_base: nil, force_cleanup: false, name_pattern: NAME_PATTERN)
|
|
16
|
+
@worktrees_root = worktrees_root
|
|
17
|
+
@default_base = default_base
|
|
18
|
+
@force_cleanup = force_cleanup
|
|
19
|
+
@name_pattern = name_pattern
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.load(config_path = default_config_path)
|
|
23
|
+
if File.exist?(config_path)
|
|
24
|
+
begin
|
|
25
|
+
config_data = YAML.load_file(config_path)
|
|
26
|
+
new(
|
|
27
|
+
worktrees_root: config_data['worktrees_root'] || DEFAULT_ROOT,
|
|
28
|
+
default_base: config_data['default_base'],
|
|
29
|
+
force_cleanup: config_data['force_cleanup'] || false,
|
|
30
|
+
name_pattern: config_data['name_pattern'] ? Regexp.new(config_data['name_pattern']) : NAME_PATTERN
|
|
31
|
+
)
|
|
32
|
+
rescue Psych::SyntaxError => e
|
|
33
|
+
raise Error, "Invalid configuration file #{config_path}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
new
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.default_config_path
|
|
41
|
+
File.join(Dir.home, '.worktrees', 'config.yml')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def valid_name?(name)
|
|
45
|
+
return false unless name.is_a?(String)
|
|
46
|
+
return false unless name.match?(@name_pattern)
|
|
47
|
+
|
|
48
|
+
# Extract feature part after NNN-
|
|
49
|
+
feature_part = name.split('-', 2)[1]
|
|
50
|
+
return false if feature_part.nil?
|
|
51
|
+
|
|
52
|
+
# Check for reserved names
|
|
53
|
+
!RESERVED_NAMES.include?(feature_part.downcase)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def expand_worktrees_root
|
|
57
|
+
if @worktrees_root.start_with?('~')
|
|
58
|
+
File.expand_path(@worktrees_root)
|
|
59
|
+
else
|
|
60
|
+
File.expand_path(@worktrees_root)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_h
|
|
65
|
+
{
|
|
66
|
+
worktrees_root: @worktrees_root,
|
|
67
|
+
default_base: @default_base,
|
|
68
|
+
force_cleanup: @force_cleanup,
|
|
69
|
+
name_pattern: @name_pattern.source
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Worktrees
|
|
6
|
+
class WorktreeManager
|
|
7
|
+
attr_reader :repository, :config
|
|
8
|
+
|
|
9
|
+
def initialize(repository = nil, config = nil)
|
|
10
|
+
begin
|
|
11
|
+
repo_root = find_git_repository_root
|
|
12
|
+
@repository = repository || Models::Repository.new(repo_root)
|
|
13
|
+
rescue GitError => e
|
|
14
|
+
# Re-raise with more context if we're not in a git repo
|
|
15
|
+
raise GitError, "Not in a git repository. #{e.message}"
|
|
16
|
+
end
|
|
17
|
+
@config = config || Models::WorktreeConfig.load
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_worktree(name, base_ref = nil, options = {})
|
|
21
|
+
# Validate arguments
|
|
22
|
+
raise ValidationError, 'Name is required' if name.nil?
|
|
23
|
+
raise ValidationError, 'Name cannot be empty' if name.empty?
|
|
24
|
+
|
|
25
|
+
# Use FeatureWorktree validation for better error messages
|
|
26
|
+
unless Models::FeatureWorktree.validate_name(name)
|
|
27
|
+
# Check what specific error to show
|
|
28
|
+
unless name.match?(Models::FeatureWorktree::NAME_PATTERN)
|
|
29
|
+
raise ValidationError, "Invalid name format '#{name}'. Names must match pattern: NNN-kebab-feature"
|
|
30
|
+
end
|
|
31
|
+
feature_part = name.split('-', 2)[1]
|
|
32
|
+
if feature_part && Models::FeatureWorktree::RESERVED_NAMES.include?(feature_part.downcase)
|
|
33
|
+
raise ValidationError, "Reserved name '#{feature_part}' not allowed in worktree names"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Use default base if none provided
|
|
38
|
+
base_ref ||= @repository.default_branch
|
|
39
|
+
|
|
40
|
+
# Check if base reference exists
|
|
41
|
+
unless @repository.branch_exists?(base_ref)
|
|
42
|
+
raise GitError, "Base reference '#{base_ref}' not found"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check for duplicate worktree
|
|
46
|
+
existing = find_worktree(name)
|
|
47
|
+
if existing
|
|
48
|
+
raise ValidationError, "Worktree '#{name}' already exists"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Create worktree path
|
|
52
|
+
worktree_path = File.join(@config.expand_worktrees_root, name)
|
|
53
|
+
|
|
54
|
+
# Create worktrees root directory if it doesn't exist
|
|
55
|
+
FileUtils.mkdir_p(@config.expand_worktrees_root)
|
|
56
|
+
|
|
57
|
+
# Create the worktree
|
|
58
|
+
unless GitOperations.create_worktree(worktree_path, name, base_ref)
|
|
59
|
+
raise GitError, "Failed to create worktree '#{name}'"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Verify worktree was created
|
|
63
|
+
unless File.directory?(worktree_path)
|
|
64
|
+
raise FileSystemError, "Worktree directory was not created: #{worktree_path}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Return the created worktree
|
|
68
|
+
Models::FeatureWorktree.new(
|
|
69
|
+
name: name,
|
|
70
|
+
path: worktree_path,
|
|
71
|
+
branch: name,
|
|
72
|
+
base_ref: base_ref,
|
|
73
|
+
status: :clean,
|
|
74
|
+
created_at: Time.now,
|
|
75
|
+
repository_path: @repository.root_path
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def list_worktrees(format: :objects, status: nil)
|
|
80
|
+
git_worktrees = GitOperations.list_worktrees
|
|
81
|
+
worktrees = []
|
|
82
|
+
|
|
83
|
+
git_worktrees.each do |git_wt|
|
|
84
|
+
next unless git_wt[:path] && File.directory?(git_wt[:path])
|
|
85
|
+
|
|
86
|
+
# Extract name from path
|
|
87
|
+
name = File.basename(git_wt[:path])
|
|
88
|
+
next unless @config.valid_name?(name)
|
|
89
|
+
|
|
90
|
+
# Determine status
|
|
91
|
+
wt_status = determine_status(git_wt[:path])
|
|
92
|
+
|
|
93
|
+
# Skip if status filter doesn't match
|
|
94
|
+
next if status && wt_status != status
|
|
95
|
+
|
|
96
|
+
worktree = Models::FeatureWorktree.new(
|
|
97
|
+
name: name,
|
|
98
|
+
path: git_wt[:path],
|
|
99
|
+
branch: git_wt[:branch] || name,
|
|
100
|
+
base_ref: detect_base_ref(git_wt[:branch] || name),
|
|
101
|
+
status: wt_status,
|
|
102
|
+
created_at: File.ctime(git_wt[:path]),
|
|
103
|
+
repository_path: @repository.root_path
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
worktrees << worktree
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
worktrees.sort_by(&:name)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find_worktree(name)
|
|
113
|
+
worktrees = list_worktrees
|
|
114
|
+
worktrees.find { |wt| wt.name == name }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def switch_to_worktree(name)
|
|
118
|
+
worktree = find_worktree(name)
|
|
119
|
+
raise ValidationError, "Worktree '#{name}' not found" unless worktree
|
|
120
|
+
|
|
121
|
+
# Check current state for warnings
|
|
122
|
+
current = current_worktree
|
|
123
|
+
if current && current.dirty?
|
|
124
|
+
warn "Warning: Previous worktree '#{current.name}' has uncommitted changes"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Change to worktree directory
|
|
128
|
+
Dir.chdir(worktree.path)
|
|
129
|
+
worktree
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def remove_worktree(name, options = {})
|
|
133
|
+
worktree = find_worktree(name)
|
|
134
|
+
raise NotFoundError, "Worktree '#{name}' not found" unless worktree
|
|
135
|
+
|
|
136
|
+
# Safety checks
|
|
137
|
+
if worktree.active?
|
|
138
|
+
raise StateError, "Cannot remove active worktree '#{name}'. Switch to a different worktree first"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if worktree.dirty? && !options[:force_untracked]
|
|
142
|
+
raise StateError, "Worktree '#{name}' has uncommitted changes. Commit or stash changes, or use --force-untracked for untracked files only"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check for unpushed commits
|
|
146
|
+
if GitOperations.has_unpushed_commits?(worktree.branch)
|
|
147
|
+
raise StateError, "Worktree '#{name}' has unpushed commits. Push commits first or use --force"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Remove the worktree
|
|
151
|
+
force = options[:force_untracked] || options[:force]
|
|
152
|
+
unless GitOperations.remove_worktree(worktree.path, force: force)
|
|
153
|
+
raise GitError, "Failed to remove worktree '#{name}'"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Optionally delete branch
|
|
157
|
+
if options[:delete_branch]
|
|
158
|
+
if GitOperations.is_merged?(worktree.branch)
|
|
159
|
+
GitOperations.delete_branch(worktree.branch)
|
|
160
|
+
else
|
|
161
|
+
raise StateError, "Branch '#{worktree.branch}' is not fully merged. Use merge-base check or --force"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def current_worktree
|
|
169
|
+
current_path = Dir.pwd
|
|
170
|
+
worktrees = list_worktrees
|
|
171
|
+
|
|
172
|
+
worktrees.find do |worktree|
|
|
173
|
+
current_path.start_with?(worktree.path)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def find_git_repository_root
|
|
180
|
+
# Use git to find the main repository root (not worktree)
|
|
181
|
+
result = `git rev-parse --git-common-dir 2>/dev/null`.strip
|
|
182
|
+
if $?.success? && !result.empty?
|
|
183
|
+
# git-common-dir returns the .git directory, we need its parent
|
|
184
|
+
File.dirname(result)
|
|
185
|
+
else
|
|
186
|
+
# Fallback: try to find repository by walking up directories
|
|
187
|
+
current_dir = Dir.pwd
|
|
188
|
+
while current_dir != '/'
|
|
189
|
+
if File.exist?(File.join(current_dir, '.git'))
|
|
190
|
+
return current_dir
|
|
191
|
+
end
|
|
192
|
+
current_dir = File.dirname(current_dir)
|
|
193
|
+
end
|
|
194
|
+
# Last fallback
|
|
195
|
+
Dir.pwd
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def determine_status(worktree_path)
|
|
200
|
+
# Check if this is the current worktree
|
|
201
|
+
current_path = Dir.pwd
|
|
202
|
+
is_current = current_path.start_with?(worktree_path)
|
|
203
|
+
|
|
204
|
+
# Check if worktree is clean
|
|
205
|
+
is_clean = GitOperations.is_clean?(worktree_path)
|
|
206
|
+
|
|
207
|
+
if is_current
|
|
208
|
+
:active
|
|
209
|
+
elsif is_clean
|
|
210
|
+
:clean
|
|
211
|
+
else
|
|
212
|
+
:dirty
|
|
213
|
+
end
|
|
214
|
+
rescue StandardError
|
|
215
|
+
:unknown
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def detect_base_ref(branch_name)
|
|
219
|
+
# Try to determine base branch using git merge-base
|
|
220
|
+
default_branch = @repository.default_branch
|
|
221
|
+
|
|
222
|
+
# Use merge-base to find the best common ancestor
|
|
223
|
+
result = `git merge-base #{branch_name} #{default_branch} 2>/dev/null`.strip
|
|
224
|
+
if $?.success? && !result.empty?
|
|
225
|
+
return default_branch
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Fallback: if branch exists and default branch exists, assume default
|
|
229
|
+
if GitOperations.branch_exists?(branch_name) && GitOperations.branch_exists?(default_branch)
|
|
230
|
+
return default_branch
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
'unknown'
|
|
234
|
+
rescue StandardError
|
|
235
|
+
'unknown'
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
data/lib/worktrees.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'worktrees/version'
|
|
4
|
+
|
|
5
|
+
require_relative 'worktrees/models/feature_worktree'
|
|
6
|
+
require_relative 'worktrees/models/repository'
|
|
7
|
+
require_relative 'worktrees/models/worktree_config'
|
|
8
|
+
|
|
9
|
+
require_relative 'worktrees/git_operations'
|
|
10
|
+
require_relative 'worktrees/worktree_manager'
|
|
11
|
+
|
|
12
|
+
require_relative 'worktrees/commands/create'
|
|
13
|
+
require_relative 'worktrees/commands/list'
|
|
14
|
+
require_relative 'worktrees/commands/switch'
|
|
15
|
+
require_relative 'worktrees/commands/remove'
|
|
16
|
+
require_relative 'worktrees/commands/status'
|
|
17
|
+
|
|
18
|
+
require_relative 'worktrees/cli'
|
|
19
|
+
|
|
20
|
+
module Worktrees
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
class ValidationError < Error; end
|
|
23
|
+
class GitError < Error; end
|
|
24
|
+
class StateError < Error; end
|
|
25
|
+
class FileSystemError < Error; end
|
|
26
|
+
class NotFoundError < Error; end
|
|
27
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Gemini CLI Agent Context
|
|
2
|
+
|
|
3
|
+
Use Gemini for broad codebase or multi-file analysis beyond Cursor context. Paths are absolute or relative to repo root.
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gemini -p "@./ Summarize this project's structure and identify any existing Git tooling"
|
|
9
|
+
|
|
10
|
+
gemini -p "@specs/001-build-a-tool/ @src/ Verify whether a worktrees CLI exists; if not, outline key modules"
|
|
11
|
+
|
|
12
|
+
gemini -p "@specs/001-build-a-tool/contracts/ Analyze CLI contracts and propose test cases for bats"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Conventions:
|
|
16
|
+
- Prefer `--all_files` when scanning the entire repo.
|
|
17
|
+
- Use `@specs/001-build-a-tool/` to keep the feature context in view.
|
|
18
|
+
- Keep prompts specific to contracts, data model, and quickstart to generate targeted insights.
|
|
19
|
+
|
|
20
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# CLI Contracts: Manage Git feature worktrees
|
|
2
|
+
|
|
3
|
+
All commands operate within a Git repository unless noted. Outputs default to human-readable text; `--format json` returns structured JSON. Errors/warnings go to stderr. Exit codes: 0 success, 2 validation error, 3 precondition failure, 4 conflict, 5 unsafe state, 6 not found.
|
|
4
|
+
|
|
5
|
+
## worktrees create <NNN-kebab-feature>
|
|
6
|
+
- Flags:
|
|
7
|
+
- `--base <ref>`: base reference; if omitted, auto-detect default base
|
|
8
|
+
- `--root <path>`: override global root (default `$HOME/.worktrees`)
|
|
9
|
+
- `--reuse-branch`: reuse existing local branch if present and not checked out
|
|
10
|
+
- `--sibling <suffix>`: create sibling branch if branch already checked out elsewhere
|
|
11
|
+
- `--format <text|json>`: output format
|
|
12
|
+
- Behavior:
|
|
13
|
+
- Validates name against `^[0-9]{3}-[a-z0-9-]{1,40}$`; reserved names disallowed
|
|
14
|
+
- Auto-fetch base if remote-only; abort with guidance on fetch failure
|
|
15
|
+
- Prevent duplicate checkout; suggest existing worktree or `--sibling`
|
|
16
|
+
- Create worktree directory under root and checkout branch (create or reuse per rules)
|
|
17
|
+
- Output (json): `{ name, branch, baseRef, path, active: true }`
|
|
18
|
+
|
|
19
|
+
## worktrees list [--filter-name <substr>] [--filter-base <branch>] [--page N] [--page-size N]
|
|
20
|
+
- Behavior: Lists known worktrees for current repository with paging
|
|
21
|
+
- Output (json): `{ items: [ { name, branch, baseRef, path, active, isDirty, hasUnpushedCommits } ], page, pageSize, total }`
|
|
22
|
+
|
|
23
|
+
## worktrees switch <name>
|
|
24
|
+
- Behavior: Switches active working copy to the specified worktree; allowed even if current worktree is dirty; prints a warning summarizing dirty state
|
|
25
|
+
- Output (json): `{ current: { name, path }, previous: { name, path }, warnings: [ ... ] }`
|
|
26
|
+
|
|
27
|
+
## worktrees remove <name>
|
|
28
|
+
- Flags:
|
|
29
|
+
- `--delete-branch`: also delete the associated branch if fully merged
|
|
30
|
+
- `--merged-into <base>`: base branch to verify full merge
|
|
31
|
+
- `--force`: allow deletion of untracked/ignored files only; tracked changes or ops in progress never allowed
|
|
32
|
+
- `--format <text|json>`
|
|
33
|
+
- Behavior: Disallows removal if tracked changes, unpushed commits/no upstream, or operation in progress. Never deletes tags.
|
|
34
|
+
- Output (json): `{ removed: true, branchDeleted: boolean }`
|
|
35
|
+
|
|
36
|
+
## worktrees status
|
|
37
|
+
- Behavior: Shows current worktree status: name, base reference (if known), path
|
|
38
|
+
- Output (json): `{ name, baseRef, path }`
|
|
39
|
+
|
|
40
|
+
## Global
|
|
41
|
+
- `--help`, `--version`, `--format`, consistent across commands
|
|
42
|
+
|
|
43
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
openapi: 3.1.0
|
|
2
|
+
info:
|
|
3
|
+
title: Worktrees Management API (contract for CLI behaviors)
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
paths:
|
|
6
|
+
/worktrees:
|
|
7
|
+
get:
|
|
8
|
+
summary: List worktrees
|
|
9
|
+
parameters:
|
|
10
|
+
- in: query
|
|
11
|
+
name: filterName
|
|
12
|
+
schema: { type: string }
|
|
13
|
+
- in: query
|
|
14
|
+
name: filterBase
|
|
15
|
+
schema: { type: string }
|
|
16
|
+
- in: query
|
|
17
|
+
name: page
|
|
18
|
+
schema: { type: integer, minimum: 1, default: 1 }
|
|
19
|
+
- in: query
|
|
20
|
+
name: pageSize
|
|
21
|
+
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
|
|
22
|
+
responses:
|
|
23
|
+
'200':
|
|
24
|
+
description: OK
|
|
25
|
+
content:
|
|
26
|
+
application/json:
|
|
27
|
+
schema:
|
|
28
|
+
type: object
|
|
29
|
+
properties:
|
|
30
|
+
items:
|
|
31
|
+
type: array
|
|
32
|
+
items:
|
|
33
|
+
$ref: '#/components/schemas/Worktree'
|
|
34
|
+
page: { type: integer }
|
|
35
|
+
pageSize: { type: integer }
|
|
36
|
+
total: { type: integer }
|
|
37
|
+
post:
|
|
38
|
+
summary: Create a worktree
|
|
39
|
+
requestBody:
|
|
40
|
+
required: true
|
|
41
|
+
content:
|
|
42
|
+
application/json:
|
|
43
|
+
schema:
|
|
44
|
+
type: object
|
|
45
|
+
required: [ name ]
|
|
46
|
+
properties:
|
|
47
|
+
name: { type: string }
|
|
48
|
+
base: { type: string }
|
|
49
|
+
root: { type: string }
|
|
50
|
+
reuseBranch: { type: boolean }
|
|
51
|
+
siblingSuffix: { type: string }
|
|
52
|
+
responses:
|
|
53
|
+
'201':
|
|
54
|
+
description: Created
|
|
55
|
+
content:
|
|
56
|
+
application/json:
|
|
57
|
+
schema:
|
|
58
|
+
$ref: '#/components/schemas/Worktree'
|
|
59
|
+
/worktrees/{name}/switch:
|
|
60
|
+
post:
|
|
61
|
+
summary: Switch to a worktree by name
|
|
62
|
+
parameters:
|
|
63
|
+
- in: path
|
|
64
|
+
name: name
|
|
65
|
+
required: true
|
|
66
|
+
schema: { type: string }
|
|
67
|
+
responses:
|
|
68
|
+
'200':
|
|
69
|
+
description: OK
|
|
70
|
+
content:
|
|
71
|
+
application/json:
|
|
72
|
+
schema:
|
|
73
|
+
type: object
|
|
74
|
+
properties:
|
|
75
|
+
current: { $ref: '#/components/schemas/WorktreeRef' }
|
|
76
|
+
previous: { $ref: '#/components/schemas/WorktreeRef' }
|
|
77
|
+
warnings:
|
|
78
|
+
type: array
|
|
79
|
+
items: { type: string }
|
|
80
|
+
/worktrees/{name}:
|
|
81
|
+
delete:
|
|
82
|
+
summary: Remove a worktree
|
|
83
|
+
parameters:
|
|
84
|
+
- in: path
|
|
85
|
+
name: name
|
|
86
|
+
required: true
|
|
87
|
+
schema: { type: string }
|
|
88
|
+
- in: query
|
|
89
|
+
name: deleteBranch
|
|
90
|
+
schema: { type: boolean }
|
|
91
|
+
- in: query
|
|
92
|
+
name: mergedInto
|
|
93
|
+
schema: { type: string }
|
|
94
|
+
- in: query
|
|
95
|
+
name: force
|
|
96
|
+
schema: { type: boolean }
|
|
97
|
+
responses:
|
|
98
|
+
'200':
|
|
99
|
+
description: OK
|
|
100
|
+
content:
|
|
101
|
+
application/json:
|
|
102
|
+
schema:
|
|
103
|
+
type: object
|
|
104
|
+
properties:
|
|
105
|
+
removed: { type: boolean }
|
|
106
|
+
branchDeleted: { type: boolean }
|
|
107
|
+
/status:
|
|
108
|
+
get:
|
|
109
|
+
summary: Show current worktree status
|
|
110
|
+
responses:
|
|
111
|
+
'200':
|
|
112
|
+
description: OK
|
|
113
|
+
content:
|
|
114
|
+
application/json:
|
|
115
|
+
schema:
|
|
116
|
+
$ref: '#/components/schemas/WorktreeRef'
|
|
117
|
+
components:
|
|
118
|
+
schemas:
|
|
119
|
+
Worktree:
|
|
120
|
+
type: object
|
|
121
|
+
properties:
|
|
122
|
+
name: { type: string }
|
|
123
|
+
branch: { type: string }
|
|
124
|
+
baseRef: { type: string }
|
|
125
|
+
path: { type: string }
|
|
126
|
+
active: { type: boolean }
|
|
127
|
+
isDirty: { type: boolean }
|
|
128
|
+
hasUnpushedCommits: { type: boolean }
|
|
129
|
+
WorktreeRef:
|
|
130
|
+
type: object
|
|
131
|
+
properties:
|
|
132
|
+
name: { type: string }
|
|
133
|
+
path: { type: string }
|
|
134
|
+
|
|
135
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Data Model: Manage Git feature worktrees
|
|
2
|
+
|
|
3
|
+
## Entities
|
|
4
|
+
|
|
5
|
+
### Repository
|
|
6
|
+
- rootPath: absolute path to repo root
|
|
7
|
+
- defaultBranch: name (detected from remote HEAD or `main`/`master`)
|
|
8
|
+
- remotes: list of `{ name, url }`
|
|
9
|
+
|
|
10
|
+
### FeatureName
|
|
11
|
+
- value: string matching `^[0-9]{3}-[a-z0-9-]{1,40}$`
|
|
12
|
+
- normalized: lowercase
|
|
13
|
+
- reserved: boolean (true if `main` or `master`)
|
|
14
|
+
|
|
15
|
+
### Worktree
|
|
16
|
+
- name: FeatureName
|
|
17
|
+
- branch: branch name (may equal `name`)
|
|
18
|
+
- baseRef: reference name used to create/rebase
|
|
19
|
+
- path: absolute filesystem path
|
|
20
|
+
- isActive: boolean
|
|
21
|
+
- checkedOut: boolean (branch checkout status)
|
|
22
|
+
- upstream: optional remote tracking branch
|
|
23
|
+
- hasUnpushedCommits: boolean
|
|
24
|
+
- isDirty: boolean (tracked changes present)
|
|
25
|
+
- hasUntracked: boolean
|
|
26
|
+
- opInProgress: one of `none|merge|rebase|cherry-pick|bisect`
|
|
27
|
+
|
|
28
|
+
### ListQuery
|
|
29
|
+
- filterName: optional substring match (case-insensitive)
|
|
30
|
+
- filterBase: optional base branch
|
|
31
|
+
- page: integer ≥ 1 (default 1)
|
|
32
|
+
- pageSize: integer (default 20, max 100)
|
|
33
|
+
|
|
34
|
+
## Relationships
|
|
35
|
+
- Repository has many Worktrees
|
|
36
|
+
- Worktree belongs to a Repository
|
|
37
|
+
- FeatureName is associated with Worktree.name and branch naming
|
|
38
|
+
|
|
39
|
+
## Validation Rules
|
|
40
|
+
- FeatureName must match `^[0-9]{3}-[a-z0-9-]{1,40}$` and be unique (case-insensitive) across existing worktrees.
|
|
41
|
+
- Reserved names `main`, `master` are disallowed for FeatureName.
|
|
42
|
+
- Branch reuse: if a local branch named FeatureName exists and is not checked out in any worktree → reuse; otherwise create.
|
|
43
|
+
- Duplicate checkout: if branch is checked out in another worktree → disallow; offer selection or sibling creation via explicit flag.
|
|
44
|
+
- Removal preconditions: disallow if tracked changes exist, op in progress, or unpushed commits/no upstream; allow `--force` only for untracked/ignored file deletion.
|
|
45
|
+
- Branch deletion allowed only with explicit opt-in and only if fully merged into a specified base (merge-base ancestor check).
|
|
46
|
+
|
|
47
|
+
## Derived State
|
|
48
|
+
- activeWorktree: computed from `git worktree list --porcelain` current path
|
|
49
|
+
- defaultBase: computed from repo remote HEAD → `main` → `master`
|
|
50
|
+
|
|
51
|
+
|