ace-git-worktree 0.19.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/.ace-defaults/git/worktree.yml +250 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +19 -0
- data/CHANGELOG.md +957 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +14 -0
- data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
- data/docs/demo/ace-git-worktree-getting-started.tape.yml +28 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +114 -0
- data/docs/handbook.md +38 -0
- data/docs/usage.md +334 -0
- data/exe/ace-git-worktree +24 -0
- data/handbook/agents/worktree.ag.md +189 -0
- data/handbook/skills/as-git-worktree/SKILL.md +27 -0
- data/handbook/skills/as-git-worktree-create/SKILL.md +21 -0
- data/handbook/skills/as-git-worktree-manage/SKILL.md +20 -0
- data/handbook/workflow-instructions/git/worktree-create.wf.md +262 -0
- data/handbook/workflow-instructions/git/worktree-manage.wf.md +384 -0
- data/handbook/workflow-instructions/git/worktree.wf.md +224 -0
- data/lib/ace/git/worktree/atoms/git_command.rb +121 -0
- data/lib/ace/git/worktree/atoms/path_expander.rb +189 -0
- data/lib/ace/git/worktree/atoms/slug_generator.rb +235 -0
- data/lib/ace/git/worktree/atoms/task_id_extractor.rb +91 -0
- data/lib/ace/git/worktree/cli/commands/config.rb +50 -0
- data/lib/ace/git/worktree/cli/commands/create.rb +80 -0
- data/lib/ace/git/worktree/cli/commands/list.rb +76 -0
- data/lib/ace/git/worktree/cli/commands/prune.rb +43 -0
- data/lib/ace/git/worktree/cli/commands/remove.rb +48 -0
- data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +66 -0
- data/lib/ace/git/worktree/cli/commands/switch.rb +44 -0
- data/lib/ace/git/worktree/cli.rb +103 -0
- data/lib/ace/git/worktree/commands/config_command.rb +351 -0
- data/lib/ace/git/worktree/commands/create_command.rb +961 -0
- data/lib/ace/git/worktree/commands/list_command.rb +247 -0
- data/lib/ace/git/worktree/commands/prune_command.rb +260 -0
- data/lib/ace/git/worktree/commands/remove_command.rb +522 -0
- data/lib/ace/git/worktree/commands/switch_command.rb +249 -0
- data/lib/ace/git/worktree/configuration.rb +167 -0
- data/lib/ace/git/worktree/models/worktree_config.rb +502 -0
- data/lib/ace/git/worktree/models/worktree_info.rb +303 -0
- data/lib/ace/git/worktree/models/worktree_metadata.rb +294 -0
- data/lib/ace/git/worktree/molecules/config_loader.rb +125 -0
- data/lib/ace/git/worktree/molecules/current_task_linker.rb +136 -0
- data/lib/ace/git/worktree/molecules/hook_executor.rb +361 -0
- data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +186 -0
- data/lib/ace/git/worktree/molecules/pr_creator.rb +253 -0
- data/lib/ace/git/worktree/molecules/task_committer.rb +329 -0
- data/lib/ace/git/worktree/molecules/task_fetcher.rb +244 -0
- data/lib/ace/git/worktree/molecules/task_pusher.rb +183 -0
- data/lib/ace/git/worktree/molecules/task_status_updater.rb +447 -0
- data/lib/ace/git/worktree/molecules/worktree_creator.rb +832 -0
- data/lib/ace/git/worktree/molecules/worktree_lister.rb +337 -0
- data/lib/ace/git/worktree/molecules/worktree_remover.rb +416 -0
- data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +906 -0
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +714 -0
- data/lib/ace/git/worktree/version.rb +9 -0
- data/lib/ace/git/worktree.rb +215 -0
- metadata +218 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Worktree
|
|
8
|
+
module Atoms
|
|
9
|
+
# Path expansion and validation for worktree operations
|
|
10
|
+
#
|
|
11
|
+
# Simplified implementation focused on worktree-specific needs without
|
|
12
|
+
# over-engineered security patterns.
|
|
13
|
+
#
|
|
14
|
+
# @example Expand a user path
|
|
15
|
+
# PathExpander.expand("~") # => "/home/user"
|
|
16
|
+
#
|
|
17
|
+
# @example Check if a directory is writable
|
|
18
|
+
# PathExpander.writable?("/tmp/worktree") # => true/false
|
|
19
|
+
class PathExpander
|
|
20
|
+
class << self
|
|
21
|
+
# Expand a path using standard Ruby path expansion
|
|
22
|
+
#
|
|
23
|
+
# @param path [String] Path to expand
|
|
24
|
+
# @param base [String, nil] Base directory for relative paths (default: current directory)
|
|
25
|
+
# @return [String] Expanded absolute path
|
|
26
|
+
def expand(path, base = nil)
|
|
27
|
+
return "" if path.nil? || path.empty?
|
|
28
|
+
base ? File.expand_path(path, base) : File.expand_path(path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resolve a path relative to a base directory
|
|
32
|
+
#
|
|
33
|
+
# @param path [String] Path to resolve
|
|
34
|
+
# @param base [String] Base directory (default: current directory)
|
|
35
|
+
# @return [String] Resolved absolute path
|
|
36
|
+
def resolve(path, base = Dir.pwd)
|
|
37
|
+
expand_path = expand(path)
|
|
38
|
+
expanded_base = expand(base)
|
|
39
|
+
|
|
40
|
+
if File.absolute_path?(expand_path)
|
|
41
|
+
expand_path
|
|
42
|
+
else
|
|
43
|
+
File.expand_path(path, expanded_base)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if a path is writable
|
|
48
|
+
#
|
|
49
|
+
# @param path [String] Path to check
|
|
50
|
+
# @param create_if_missing [Boolean] Create directory if it doesn't exist
|
|
51
|
+
# @return [Boolean] true if path is writable
|
|
52
|
+
def writable?(path, create_if_missing: false)
|
|
53
|
+
expanded_path = expand(path)
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
# Check if path exists
|
|
57
|
+
unless File.exist?(expanded_path)
|
|
58
|
+
if create_if_missing
|
|
59
|
+
FileUtils.mkdir_p(expanded_path)
|
|
60
|
+
else
|
|
61
|
+
return false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Test writability by trying to create a temp file
|
|
66
|
+
temp_file = File.join(expanded_path, ".ace_git_worktree_test_#{Time.now.to_i}_#{$$}")
|
|
67
|
+
File.write(temp_file, "test")
|
|
68
|
+
File.delete(temp_file)
|
|
69
|
+
true
|
|
70
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
71
|
+
false
|
|
72
|
+
rescue
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Validate a path for worktree creation
|
|
78
|
+
#
|
|
79
|
+
# @param path [String] Path to validate
|
|
80
|
+
# @param git_root [String] Git repository root
|
|
81
|
+
# @return [Hash] Validation result with :valid, :error, :expanded_path keys
|
|
82
|
+
def validate_for_worktree(path, git_root = nil)
|
|
83
|
+
expanded_path = expand(path)
|
|
84
|
+
|
|
85
|
+
# Check if parent directory exists and is writable
|
|
86
|
+
parent_dir = File.dirname(expanded_path)
|
|
87
|
+
unless File.exist?(parent_dir)
|
|
88
|
+
return {
|
|
89
|
+
valid: false,
|
|
90
|
+
error: "Parent directory does not exist: #{parent_dir}",
|
|
91
|
+
expanded_path: expanded_path
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unless writable?(parent_dir)
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
error: "Parent directory is not writable: #{parent_dir}",
|
|
99
|
+
expanded_path: expanded_path
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Check if worktree is being created directly in git root (not allowed)
|
|
104
|
+
# Allow worktrees in subdirectories or outside git root entirely
|
|
105
|
+
if git_root
|
|
106
|
+
git_root_expanded = File.expand_path(git_root)
|
|
107
|
+
expanded_path_abs = File.expand_path(expanded_path)
|
|
108
|
+
|
|
109
|
+
# Only prevent creation directly at git root, not in subdirectories
|
|
110
|
+
if expanded_path_abs == git_root_expanded
|
|
111
|
+
return {
|
|
112
|
+
valid: false,
|
|
113
|
+
error: "Worktree cannot be created at git repository root. Use a subdirectory or different location.",
|
|
114
|
+
expanded_path: expanded_path
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Check if path already exists
|
|
120
|
+
if File.exist?(expanded_path)
|
|
121
|
+
if File.directory?(expanded_path) && !Dir.empty?(expanded_path)
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
error: "Directory already exists and is not empty: #{expanded_path}",
|
|
125
|
+
expanded_path: expanded_path
|
|
126
|
+
}
|
|
127
|
+
elsif File.file?(expanded_path)
|
|
128
|
+
return {
|
|
129
|
+
valid: false,
|
|
130
|
+
error: "Path exists but is a file: #{expanded_path}",
|
|
131
|
+
expanded_path: expanded_path
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
valid: true,
|
|
138
|
+
error: nil,
|
|
139
|
+
expanded_path: expanded_path
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get a relative path from git root
|
|
144
|
+
#
|
|
145
|
+
# @param path [String] Absolute path
|
|
146
|
+
# @param git_root [String] Git repository root
|
|
147
|
+
# @return [String] Relative path from git root
|
|
148
|
+
def relative_to_git_root(path, git_root)
|
|
149
|
+
expanded_path = expand(path)
|
|
150
|
+
expanded_root = expand(git_root)
|
|
151
|
+
|
|
152
|
+
# Use File.expand_path for consistent comparison
|
|
153
|
+
normalized_path = File.expand_path(expanded_path)
|
|
154
|
+
normalized_root = File.expand_path(expanded_root)
|
|
155
|
+
|
|
156
|
+
if normalized_path.start_with?(normalized_root + "/") || normalized_path == normalized_root
|
|
157
|
+
if normalized_path == normalized_root
|
|
158
|
+
"."
|
|
159
|
+
else
|
|
160
|
+
relative_path = normalized_path[normalized_root.length..-1]
|
|
161
|
+
relative_path.start_with?("/") ? relative_path[1..-1] : relative_path
|
|
162
|
+
end
|
|
163
|
+
else
|
|
164
|
+
expanded_path
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Simple path safety validation
|
|
169
|
+
# Only checks for obviously dangerous patterns
|
|
170
|
+
#
|
|
171
|
+
# @param path [String] Path to validate
|
|
172
|
+
# @return [Boolean] true if path appears safe
|
|
173
|
+
def safe_path?(path)
|
|
174
|
+
return false if path.nil? || path.empty?
|
|
175
|
+
|
|
176
|
+
path_str = path.to_s
|
|
177
|
+
|
|
178
|
+
# Check for null bytes and obviously dangerous patterns
|
|
179
|
+
return false if path_str.include?("\x00")
|
|
180
|
+
return false if path_str.include?("../../../")
|
|
181
|
+
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Worktree
|
|
6
|
+
module Atoms
|
|
7
|
+
# Slug generation atom
|
|
8
|
+
#
|
|
9
|
+
# Converts task titles and other text into URL-safe slugs suitable
|
|
10
|
+
# for git branch names and directory names.
|
|
11
|
+
#
|
|
12
|
+
# @example Generate a slug from a task title
|
|
13
|
+
# SlugGenerator.from_title("Fix authentication bug in login flow")
|
|
14
|
+
# # => "fix-authentication-bug-in-login-flow"
|
|
15
|
+
#
|
|
16
|
+
# @example Generate a slug with custom max length
|
|
17
|
+
# SlugGenerator.from_title("Very long task title...", max_length: 20)
|
|
18
|
+
# # => "very-long-task-title"
|
|
19
|
+
class SlugGenerator
|
|
20
|
+
# Default maximum length for slugs
|
|
21
|
+
DEFAULT_MAX_LENGTH = 50
|
|
22
|
+
|
|
23
|
+
# Minimum length to avoid empty or too-short slugs
|
|
24
|
+
MIN_LENGTH = 3
|
|
25
|
+
|
|
26
|
+
# Characters that are not allowed in git branch names
|
|
27
|
+
FORBIDDEN_CHARS = /[~\^*?\[\]:]/
|
|
28
|
+
|
|
29
|
+
# Characters to replace with hyphens
|
|
30
|
+
SEPARATORS = /[ ._\/\\()]+/
|
|
31
|
+
|
|
32
|
+
# Multiple consecutive non-word characters (but allow single hyphens)
|
|
33
|
+
MULTIPLE_SEPARATORS = /-{2,}|[^a-zA-Z0-9-]+/
|
|
34
|
+
|
|
35
|
+
# Leading/trailing separators and hyphens
|
|
36
|
+
TRIM_SEPARATORS = /^[-_]+|[-_]+$/
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Generate a slug from a task title
|
|
40
|
+
#
|
|
41
|
+
# @param title [String] Task title to convert
|
|
42
|
+
# @param max_length [Integer] Maximum length of the slug
|
|
43
|
+
# @param fallback [String] Fallback string if slug is too short/empty
|
|
44
|
+
# @return [String] URL-safe slug
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# SlugGenerator.from_title("Fix authentication bug")
|
|
48
|
+
# # => "fix-authentication-bug"
|
|
49
|
+
#
|
|
50
|
+
# SlugGenerator.from_title("Add user:profile endpoint", max_length: 20)
|
|
51
|
+
# # => "add-user-profile-end"
|
|
52
|
+
def from_title(title, max_length: DEFAULT_MAX_LENGTH, fallback: "task")
|
|
53
|
+
return fallback if title.nil? || title.empty?
|
|
54
|
+
|
|
55
|
+
# Convert to string and strip whitespace
|
|
56
|
+
title_str = title.to_s.strip
|
|
57
|
+
|
|
58
|
+
# Remove forbidden characters that git doesn't allow in branch names
|
|
59
|
+
cleaned = title_str.gsub(FORBIDDEN_CHARS, "")
|
|
60
|
+
|
|
61
|
+
# Replace separators (spaces, dots, underscores, slashes) with hyphens
|
|
62
|
+
normalized = cleaned.gsub(SEPARATORS, "-")
|
|
63
|
+
|
|
64
|
+
# Remove any remaining non-alphanumeric characters except hyphens
|
|
65
|
+
sanitized = normalized.gsub(MULTIPLE_SEPARATORS, "-")
|
|
66
|
+
|
|
67
|
+
# Remove leading/trailing hyphens and underscores
|
|
68
|
+
trimmed = sanitized.gsub(TRIM_SEPARATORS, "")
|
|
69
|
+
|
|
70
|
+
# Convert to lowercase
|
|
71
|
+
slug = trimmed.downcase
|
|
72
|
+
|
|
73
|
+
# Handle empty or too-short result
|
|
74
|
+
if slug.length < MIN_LENGTH
|
|
75
|
+
slug = fallback
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Truncate to max length, avoiding cutting in the middle of a word if possible
|
|
79
|
+
if slug.length > max_length
|
|
80
|
+
slug = truncate_slug(slug, max_length)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Final validation - ensure we still have a valid slug
|
|
84
|
+
if slug.empty? || slug.length < MIN_LENGTH
|
|
85
|
+
slug = fallback
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
slug
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Generate a slug for a task ID
|
|
92
|
+
#
|
|
93
|
+
# @param task_id [String] Task ID (e.g., "081", "task.081", "v.0.9.0+081")
|
|
94
|
+
# @return [String] Simple task ID slug
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# SlugGenerator.from_task_id("081") # => "task-081"
|
|
98
|
+
# SlugGenerator.from_task_id("v.0.9.0+081") # => "task-081"
|
|
99
|
+
def from_task_id(task_id)
|
|
100
|
+
return "task" if task_id.nil? || task_id.empty?
|
|
101
|
+
|
|
102
|
+
# Extract the numeric part from various task ID formats
|
|
103
|
+
numeric_part = task_id.to_s.match(/(\d+)$/)
|
|
104
|
+
return "task-#{task_id}" unless numeric_part
|
|
105
|
+
|
|
106
|
+
"task-#{numeric_part[1]}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Generate a combined slug from task ID and title
|
|
110
|
+
#
|
|
111
|
+
# @param task_id [String] Task ID
|
|
112
|
+
# @param title [String] Task title
|
|
113
|
+
# @param max_length [Integer] Maximum length of the combined slug
|
|
114
|
+
# @return [String] Combined slug like "081-fix-authentication-bug"
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
117
|
+
# SlugGenerator.combined("081", "Fix authentication bug")
|
|
118
|
+
# # => "081-fix-authentication-bug"
|
|
119
|
+
def combined(task_id, title, max_length: DEFAULT_MAX_LENGTH)
|
|
120
|
+
task_slug = from_task_id(task_id).gsub(/^task-/, "")
|
|
121
|
+
title_slug = from_title(title, max_length: max_length - task_slug.length - 1)
|
|
122
|
+
|
|
123
|
+
"#{task_slug}-#{title_slug}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Sanitize an existing slug
|
|
127
|
+
#
|
|
128
|
+
# @param slug [String] Slug to sanitize
|
|
129
|
+
# @return [String] Sanitized slug
|
|
130
|
+
#
|
|
131
|
+
# @example
|
|
132
|
+
# SlugGenerator.sanitize("invalid@branch#name") # => "invalid-branch-name"
|
|
133
|
+
def sanitize(slug)
|
|
134
|
+
return "task" if slug.nil? || slug.empty?
|
|
135
|
+
|
|
136
|
+
slug_str = slug.to_s.strip
|
|
137
|
+
|
|
138
|
+
# Remove forbidden characters
|
|
139
|
+
cleaned = slug_str.gsub(FORBIDDEN_CHARS, "")
|
|
140
|
+
|
|
141
|
+
# Replace separators with hyphens
|
|
142
|
+
normalized = cleaned.gsub(SEPARATORS, "-")
|
|
143
|
+
|
|
144
|
+
# Remove multiple consecutive separators
|
|
145
|
+
sanitized = normalized.gsub(MULTIPLE_SEPARATORS, "-")
|
|
146
|
+
|
|
147
|
+
# Trim leading/trailing separators
|
|
148
|
+
trimmed = sanitized.gsub(TRIM_SEPARATORS, "")
|
|
149
|
+
|
|
150
|
+
# Convert to lowercase
|
|
151
|
+
slug = trimmed.downcase
|
|
152
|
+
|
|
153
|
+
# Fallback if result is empty
|
|
154
|
+
slug.empty? ? "task" : slug
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Convert a branch name to a safe directory name
|
|
158
|
+
#
|
|
159
|
+
# Sanitizes branch names for use as directory names by replacing
|
|
160
|
+
# characters that are not alphanumeric, hyphens, or underscores with hyphens.
|
|
161
|
+
# Also handles slash-separated branch names (e.g., "origin/feature/auth").
|
|
162
|
+
#
|
|
163
|
+
# @param branch_name [String] Branch name to convert
|
|
164
|
+
# @return [String] Directory-safe name
|
|
165
|
+
#
|
|
166
|
+
# @example Simple branch name
|
|
167
|
+
# SlugGenerator.to_directory_name("feature-branch")
|
|
168
|
+
# # => "feature-branch"
|
|
169
|
+
#
|
|
170
|
+
# @example Branch with slashes (remote or hierarchical)
|
|
171
|
+
# SlugGenerator.to_directory_name("origin/feature/auth")
|
|
172
|
+
# # => "origin-feature-auth"
|
|
173
|
+
#
|
|
174
|
+
# @example Branch with special characters
|
|
175
|
+
# SlugGenerator.to_directory_name("fix:bug#123")
|
|
176
|
+
# # => "fix-bug-123"
|
|
177
|
+
def to_directory_name(branch_name)
|
|
178
|
+
return "worktree" if branch_name.nil? || branch_name.empty?
|
|
179
|
+
|
|
180
|
+
# Replace slashes and any non-alphanumeric/hyphen/underscore with hyphens
|
|
181
|
+
branch_name.to_s.tr("/", "-").gsub(/[^a-zA-Z0-9\-_]/, "-")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Check if a slug is valid for git branch names
|
|
185
|
+
#
|
|
186
|
+
# @param slug [String] Slug to validate
|
|
187
|
+
# @return [Boolean] true if slug is valid
|
|
188
|
+
#
|
|
189
|
+
# @example
|
|
190
|
+
# SlugGenerator.valid?("valid-branch-name") # => true
|
|
191
|
+
# SlugGenerator.valid?("invalid@branch") # => false
|
|
192
|
+
def valid?(slug)
|
|
193
|
+
return false if slug.nil? || slug.empty?
|
|
194
|
+
return false if slug.length > 255 # Git branch name limit
|
|
195
|
+
return false if slug.match?(FORBIDDEN_CHARS)
|
|
196
|
+
return false if slug.start_with?("-") || slug.end_with?("-")
|
|
197
|
+
return false if slug.include?(".") # Dots not allowed in git branch names
|
|
198
|
+
return false if slug.include?(" ") # Spaces not allowed
|
|
199
|
+
|
|
200
|
+
# Check for invalid git branch name patterns
|
|
201
|
+
return false if slug == "HEAD"
|
|
202
|
+
return false if slug.include?("..")
|
|
203
|
+
return false if slug.include?("@")
|
|
204
|
+
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# Truncate slug intelligently to avoid cutting words
|
|
211
|
+
#
|
|
212
|
+
# @param slug [String] Slug to truncate
|
|
213
|
+
# @param max_length [Integer] Maximum length
|
|
214
|
+
# @return [String] Truncated slug
|
|
215
|
+
def truncate_slug(slug, max_length)
|
|
216
|
+
return slug if slug.length <= max_length
|
|
217
|
+
|
|
218
|
+
# Try to truncate at a hyphen to avoid cutting words
|
|
219
|
+
truncated = slug[0, max_length]
|
|
220
|
+
|
|
221
|
+
# Find the last hyphen before the cutoff
|
|
222
|
+
last_hyphen = truncated.rindex("-")
|
|
223
|
+
if last_hyphen && last_hyphen > max_length * 0.7 # Don't cut too much
|
|
224
|
+
truncated = slug[0, last_hyphen]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Remove trailing hyphen
|
|
228
|
+
truncated.gsub(/-$/, "")
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Worktree
|
|
6
|
+
module Atoms
|
|
7
|
+
# Extracts task IDs from task data, preserving hierarchical subtask notation
|
|
8
|
+
#
|
|
9
|
+
# This atom provides a single source of truth for task ID extraction across
|
|
10
|
+
# ace-git-worktree, ensuring consistent handling of hierarchical task IDs
|
|
11
|
+
# (e.g., "121.01" for subtasks).
|
|
12
|
+
#
|
|
13
|
+
# @example Extract from task data hash
|
|
14
|
+
# TaskIDExtractor.extract({id: "v.0.9.0+task.121.01"}) # => "121.01"
|
|
15
|
+
# TaskIDExtractor.extract({id: "v.0.9.0+task.121"}) # => "121"
|
|
16
|
+
# TaskIDExtractor.extract({id: "v.0.9.0+ace-t.121.01"}) # => "121.01"
|
|
17
|
+
# TaskIDExtractor.extract({id: "v.0.9.0+t.121"}) # => "121"
|
|
18
|
+
#
|
|
19
|
+
# @example Normalize a task reference string
|
|
20
|
+
# TaskIDExtractor.normalize("v.0.9.0+task.121.01") # => "121.01"
|
|
21
|
+
# TaskIDExtractor.normalize("task.121") # => "121"
|
|
22
|
+
# TaskIDExtractor.normalize("ace-t.121") # => "121"
|
|
23
|
+
# TaskIDExtractor.normalize("121.01") # => "121.01"
|
|
24
|
+
class TaskIDExtractor
|
|
25
|
+
# Extract task ID from task data hash
|
|
26
|
+
#
|
|
27
|
+
# @param task_data [Hash] Task data with :id and/or :task_number
|
|
28
|
+
# @return [String] Task ID (e.g., "121" or "121.01")
|
|
29
|
+
def self.extract(task_data)
|
|
30
|
+
return "unknown" unless task_data
|
|
31
|
+
|
|
32
|
+
# Regex with subtask support
|
|
33
|
+
if task_data[:id]
|
|
34
|
+
# B36TS full ID: "8pp.t.hy4" -> "hy4", or subtask "8pp.t.hy4.a" -> "hy4.a"
|
|
35
|
+
if match = task_data[:id].match(/\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3}(?:\.[a-z0-9])?)\z/)
|
|
36
|
+
return match[1]
|
|
37
|
+
end
|
|
38
|
+
# Try subtask pattern first (e.g., "v.0.9.0+task.121.01" -> "121.01")
|
|
39
|
+
if match = task_data[:id].match(/(?:ace-)?(?:task|t)\.(\d+)\.(\d{2})$/)
|
|
40
|
+
return "#{match[1]}.#{match[2]}"
|
|
41
|
+
end
|
|
42
|
+
# Then simple pattern (e.g., "v.0.9.0+task.094" -> "094")
|
|
43
|
+
if match = task_data[:id].match(/(?:ace-)?(?:task|t)\.(\d+)$/)
|
|
44
|
+
return match[1]
|
|
45
|
+
end
|
|
46
|
+
# Fallback for partial patterns (e.g., "task.121.1" -> "121", ignoring invalid suffix)
|
|
47
|
+
if match = task_data[:id].match(/(?:ace-)?(?:task|t)\.(\d+)/)
|
|
48
|
+
return match[1]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Last resort: use task_number (doesn't include subtask suffix)
|
|
53
|
+
task_data[:task_number]&.to_s || "unknown"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Normalize a task reference string to simple ID format
|
|
57
|
+
#
|
|
58
|
+
# @param task_ref [String] Task reference (e.g., "121.01", "v.0.9.0+task.121.01")
|
|
59
|
+
# @return [String, nil] Normalized ID or nil if invalid
|
|
60
|
+
def self.normalize(task_ref)
|
|
61
|
+
ref = task_ref.to_s.strip
|
|
62
|
+
return nil if ref.empty?
|
|
63
|
+
|
|
64
|
+
# Regex patterns for recognized ACE task ID formats
|
|
65
|
+
# B36TS full ID: "8pp.t.hy4" -> "hy4", or subtask "8pp.t.hy4.a" -> "hy4.a"
|
|
66
|
+
if match = ref.match(/\A[0-9a-z]{3}\.[a-z]\.([0-9a-z]{3}(?:\.[a-z0-9])?)\z/)
|
|
67
|
+
match[1]
|
|
68
|
+
# B36TS short ref: exactly 3 lowercase alphanumeric chars (e.g., "hy4")
|
|
69
|
+
elsif match = ref.match(/\A([0-9a-z]{3})\z/)
|
|
70
|
+
match[1]
|
|
71
|
+
# ACE task IDs are 3-digit zero-padded (081, 121, etc.)
|
|
72
|
+
# Try hierarchical pattern first (e.g., "121.01", "task.121.01")
|
|
73
|
+
elsif match = ref.match(/(\d{3})\.(\d{2})(?:\b|$)/)
|
|
74
|
+
"#{match[1]}.#{match[2]}"
|
|
75
|
+
# Try ace-task/ace-t or task/t directory at end of path (e.g., "ace-task.hy4", "/path/to/task.121")
|
|
76
|
+
elsif match = ref.match(/(?:ace-)?(?:task|t)\.([0-9a-z]{3})\/?$/)
|
|
77
|
+
match[1]
|
|
78
|
+
# Try task./t. prefix in middle of path (e.g., ".cache/test/task.121")
|
|
79
|
+
# Use negative lookbehind to skip ace-task.XXX / ace-t.XXX parent directories
|
|
80
|
+
elsif match = ref.match(/(?<!ace-)(?:task|t)\.(\d{3})(?:\b|$)/)
|
|
81
|
+
match[1]
|
|
82
|
+
# Try bare 3-digit task ID (e.g., "121", "081")
|
|
83
|
+
elsif match = ref.match(/\b(\d{3})\b/)
|
|
84
|
+
match[1]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "shared_helpers"
|
|
5
|
+
require_relative "../../commands/config_command"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
module Worktree
|
|
10
|
+
module CLI
|
|
11
|
+
module Commands
|
|
12
|
+
class Config < Ace::Support::Cli::Command
|
|
13
|
+
include SharedHelpers
|
|
14
|
+
|
|
15
|
+
desc "Show and manage worktree configuration"
|
|
16
|
+
|
|
17
|
+
example [
|
|
18
|
+
" # Show current configuration",
|
|
19
|
+
"--show # Show current configuration",
|
|
20
|
+
"--validate # Validate configuration",
|
|
21
|
+
"--files # Show config file locations"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Accept extra positional arguments for backward compatibility
|
|
25
|
+
# (e.g., "show", "validate" as positional args instead of flags)
|
|
26
|
+
argument :subcommand, required: false, desc: "Subcommand (show, validate, files)"
|
|
27
|
+
|
|
28
|
+
option :show, desc: "Show current configuration", type: :boolean, aliases: []
|
|
29
|
+
option :validate, desc: "Validate configuration", type: :boolean, aliases: []
|
|
30
|
+
option :files, desc: "Show configuration file locations", type: :boolean, aliases: []
|
|
31
|
+
option :verbose, desc: "Show verbose output", type: :boolean, aliases: ["-v"]
|
|
32
|
+
option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress non-essential output"
|
|
33
|
+
option :debug, type: :boolean, aliases: ["-d"], desc: "Show debug output"
|
|
34
|
+
|
|
35
|
+
def call(subcommand: nil, **options)
|
|
36
|
+
display_config_summary("config", options)
|
|
37
|
+
|
|
38
|
+
# Convert ace-support-cli options to args array format
|
|
39
|
+
args = options_to_args(options)
|
|
40
|
+
# Add subcommand as positional argument if provided
|
|
41
|
+
args << subcommand if subcommand
|
|
42
|
+
|
|
43
|
+
Ace::Git::Worktree::Commands::ConfigCommand.new.run(args)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "shared_helpers"
|
|
5
|
+
require_relative "../../commands/create_command"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
module Worktree
|
|
10
|
+
module CLI
|
|
11
|
+
module Commands
|
|
12
|
+
class Create < Ace::Support::Cli::Command
|
|
13
|
+
include SharedHelpers
|
|
14
|
+
|
|
15
|
+
desc <<~DESC.strip
|
|
16
|
+
Create a new worktree
|
|
17
|
+
|
|
18
|
+
Supports task-aware, PR, and traditional worktree creation.
|
|
19
|
+
|
|
20
|
+
Task-Aware:
|
|
21
|
+
ace-git-worktree --task 081
|
|
22
|
+
ace-git-worktree --task 081 --dry-run
|
|
23
|
+
|
|
24
|
+
PR-Aware:
|
|
25
|
+
ace-git-worktree --pr 123
|
|
26
|
+
|
|
27
|
+
Traditional:
|
|
28
|
+
ace-git-worktree feature-branch
|
|
29
|
+
ace-git-worktree create --from origin/feature
|
|
30
|
+
DESC
|
|
31
|
+
|
|
32
|
+
example [
|
|
33
|
+
"--task 081 # Create worktree for task",
|
|
34
|
+
"--pr 123 # Create worktree for PR",
|
|
35
|
+
"--from origin/feature # Create from remote branch",
|
|
36
|
+
"feature/new-auth # Create with branch name"
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
argument :branch, required: false, desc: "Branch name for traditional creation"
|
|
40
|
+
|
|
41
|
+
option :task, desc: "Task ID for task-aware worktree", aliases: []
|
|
42
|
+
option :pr, desc: "PR number for PR-aware worktree", aliases: ["--pull-request"]
|
|
43
|
+
option :from, desc: "Create from specific branch (local or remote)", aliases: ["-b"]
|
|
44
|
+
option :path, desc: "Custom worktree path", aliases: []
|
|
45
|
+
option :source, desc: "Git ref to use as start-point", aliases: []
|
|
46
|
+
option :dry_run, desc: "Show what would be created", type: :boolean, aliases: ["--dry-run"]
|
|
47
|
+
option :no_status_update, desc: "Skip marking task as in-progress", type: :boolean, aliases: ["--no-status-update"]
|
|
48
|
+
option :no_commit, desc: "Skip committing task changes", type: :boolean, aliases: ["--no-commit"]
|
|
49
|
+
option :no_push, desc: "Skip pushing task changes", type: :boolean, aliases: ["--no-push"]
|
|
50
|
+
option :no_upstream, desc: "Skip pushing with upstream tracking", type: :boolean, aliases: ["--no-upstream"]
|
|
51
|
+
option :no_pr, desc: "Skip creating draft PR", type: :boolean, aliases: ["--no-pr"]
|
|
52
|
+
option :push_remote, desc: "Remote to push to", aliases: []
|
|
53
|
+
option :no_auto_navigate, desc: "Stay in current directory", type: :boolean, aliases: ["--no-auto-navigate"]
|
|
54
|
+
option :commit_message, desc: "Custom commit message", aliases: []
|
|
55
|
+
option :target_branch, desc: "Override PR target branch (default: auto-detect from parent)", aliases: []
|
|
56
|
+
option :force, desc: "Create even if worktree exists", type: :boolean, aliases: []
|
|
57
|
+
option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress non-essential output"
|
|
58
|
+
option :verbose, type: :boolean, aliases: ["-v"], desc: "Show verbose output"
|
|
59
|
+
option :debug, type: :boolean, aliases: ["-d"], desc: "Show debug output"
|
|
60
|
+
|
|
61
|
+
def call(branch: nil, **options)
|
|
62
|
+
display_config_summary("create", options)
|
|
63
|
+
|
|
64
|
+
# Convert --from to --source for the underlying command
|
|
65
|
+
if options[:from]
|
|
66
|
+
options[:source] = options.delete(:from)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Convert ace-support-cli options hash to args array format
|
|
70
|
+
args = options_to_args(options)
|
|
71
|
+
args << branch if branch
|
|
72
|
+
|
|
73
|
+
Ace::Git::Worktree::Commands::CreateCommand.new.run(args)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|