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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/plan.md +42 -0
  3. data/.claude/commands/specify.md +12 -0
  4. data/.claude/commands/tasks.md +60 -0
  5. data/.cursor/commands/plan.md +42 -0
  6. data/.cursor/commands/specify.md +12 -0
  7. data/.cursor/commands/tasks.md +60 -0
  8. data/.cursor/rules +81 -0
  9. data/.specify/memory/constitution-v1.0.1-formal.md +90 -0
  10. data/.specify/memory/constitution.md +153 -0
  11. data/.specify/memory/constitution_update_checklist.md +88 -0
  12. data/.specify/scripts/bash/check-task-prerequisites.sh +15 -0
  13. data/.specify/scripts/bash/common.sh +37 -0
  14. data/.specify/scripts/bash/create-new-feature.sh +58 -0
  15. data/.specify/scripts/bash/get-feature-paths.sh +7 -0
  16. data/.specify/scripts/bash/setup-plan.sh +17 -0
  17. data/.specify/scripts/bash/update-agent-context.sh +57 -0
  18. data/.specify/templates/agent-file-template.md +23 -0
  19. data/.specify/templates/plan-template.md +254 -0
  20. data/.specify/templates/spec-template.md +116 -0
  21. data/.specify/templates/tasks-template.md +152 -0
  22. data/CLAUDE.md +145 -0
  23. data/Gemfile +15 -0
  24. data/Gemfile.lock +150 -0
  25. data/README.md +163 -0
  26. data/Rakefile +52 -0
  27. data/exe/worktrees +52 -0
  28. data/lib/worktrees/cli.rb +36 -0
  29. data/lib/worktrees/commands/create.rb +74 -0
  30. data/lib/worktrees/commands/list.rb +87 -0
  31. data/lib/worktrees/commands/remove.rb +62 -0
  32. data/lib/worktrees/commands/status.rb +95 -0
  33. data/lib/worktrees/commands/switch.rb +57 -0
  34. data/lib/worktrees/git_operations.rb +106 -0
  35. data/lib/worktrees/models/feature_worktree.rb +92 -0
  36. data/lib/worktrees/models/repository.rb +75 -0
  37. data/lib/worktrees/models/worktree_config.rb +74 -0
  38. data/lib/worktrees/version.rb +5 -0
  39. data/lib/worktrees/worktree_manager.rb +238 -0
  40. data/lib/worktrees.rb +27 -0
  41. data/specs/001-build-a-tool/GEMINI.md +20 -0
  42. data/specs/001-build-a-tool/contracts/cli-contracts.md +43 -0
  43. data/specs/001-build-a-tool/contracts/openapi.yaml +135 -0
  44. data/specs/001-build-a-tool/data-model.md +51 -0
  45. data/specs/001-build-a-tool/plan.md +241 -0
  46. data/specs/001-build-a-tool/quickstart.md +67 -0
  47. data/specs/001-build-a-tool/research.md +76 -0
  48. data/specs/001-build-a-tool/spec.md +153 -0
  49. data/specs/001-build-a-tool/tasks.md +209 -0
  50. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Worktrees
4
+ VERSION = '0.1.0'
5
+ 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
+