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,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/task_id_extractor"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Worktree
|
|
8
|
+
module Models
|
|
9
|
+
# Worktree information model
|
|
10
|
+
#
|
|
11
|
+
# Represents information about a git worktree, including its path,
|
|
12
|
+
# branch, commit, and associated task metadata.
|
|
13
|
+
#
|
|
14
|
+
# @example Create from git worktree list output
|
|
15
|
+
# info = WorktreeInfo.from_git_output("/path/to/worktree abc123 [branch-name]")
|
|
16
|
+
#
|
|
17
|
+
# @example Create manually
|
|
18
|
+
# info = WorktreeInfo.new(
|
|
19
|
+
# path: "/project/.ace-wt/task.081",
|
|
20
|
+
# branch: "081-fix-auth-bug",
|
|
21
|
+
# commit: "abc123",
|
|
22
|
+
# task_id: "081"
|
|
23
|
+
# )
|
|
24
|
+
class WorktreeInfo
|
|
25
|
+
attr_reader :path, :branch, :commit, :task_id, :bare, :detached, :created_at
|
|
26
|
+
|
|
27
|
+
# Initialize a new WorktreeInfo
|
|
28
|
+
#
|
|
29
|
+
# @param path [String] Path to the worktree directory
|
|
30
|
+
# @param branch [String, nil] Branch name (nil if detached HEAD)
|
|
31
|
+
# @param commit [String] Commit hash
|
|
32
|
+
# @param task_id [String, nil] Associated task ID
|
|
33
|
+
# @param bare [Boolean] Whether this is a bare worktree
|
|
34
|
+
# @param detached [Boolean] Whether worktree is in detached HEAD state
|
|
35
|
+
# @param created_at [Time, nil] When the worktree was created
|
|
36
|
+
def initialize(path:, commit:, branch: nil, task_id: nil, bare: false, detached: false, created_at: nil)
|
|
37
|
+
@path = path
|
|
38
|
+
@branch = branch
|
|
39
|
+
@commit = commit
|
|
40
|
+
@task_id = task_id
|
|
41
|
+
@bare = bare
|
|
42
|
+
@detached = detached
|
|
43
|
+
@created_at = created_at
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if the worktree is associated with a task
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean] true if task_id is present
|
|
49
|
+
def task_associated?
|
|
50
|
+
!@task_id.nil? && !@task_id.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if the worktree is in a usable state
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean] true if worktree is not bare or detached
|
|
56
|
+
def usable?
|
|
57
|
+
!@bare && !@detached
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get the worktree directory name
|
|
61
|
+
#
|
|
62
|
+
# @return [String] Directory name (basename of path)
|
|
63
|
+
def directory_name
|
|
64
|
+
File.basename(@path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if the worktree directory exists
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean] true if directory exists
|
|
70
|
+
def exists?
|
|
71
|
+
File.directory?(@path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if the worktree directory is empty
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean] true if directory is empty
|
|
77
|
+
def empty?
|
|
78
|
+
return true unless exists?
|
|
79
|
+
|
|
80
|
+
Dir.empty?(@path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get a human-readable description
|
|
84
|
+
#
|
|
85
|
+
# @return [String] Human-readable description
|
|
86
|
+
def description
|
|
87
|
+
if task_associated?
|
|
88
|
+
"Task #{@task_id}: #{@branch} at #{@path}"
|
|
89
|
+
else
|
|
90
|
+
"#{@branch || @commit[0, 8]} at #{@path}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Parse git worktree list output line
|
|
95
|
+
#
|
|
96
|
+
# @param line [String] Output line from `git worktree list`
|
|
97
|
+
# @return [WorktreeInfo, nil] Parsed worktree info or nil if parsing failed
|
|
98
|
+
#
|
|
99
|
+
# @example
|
|
100
|
+
# WorktreeInfo.from_git_output("/path/to/worktree abc123 [branch-name]")
|
|
101
|
+
# WorktreeInfo.from_git_output("/path/to/worktree abc123 (detached HEAD)")
|
|
102
|
+
def self.from_git_output(line)
|
|
103
|
+
return nil if line.nil? || line.strip.empty?
|
|
104
|
+
|
|
105
|
+
# Git worktree list format:
|
|
106
|
+
# /path/to/worktree abc123 [branch-name]
|
|
107
|
+
# /path/to/worktree abc123 (detached HEAD)
|
|
108
|
+
# /path/to/worktree abc123 + [branch-name] (worktree has changes)
|
|
109
|
+
|
|
110
|
+
parts = line.strip.split(/\s+/, 3)
|
|
111
|
+
return nil if parts.length < 2
|
|
112
|
+
|
|
113
|
+
path = parts[0]
|
|
114
|
+
commit = parts[1]
|
|
115
|
+
branch = nil
|
|
116
|
+
bare = false
|
|
117
|
+
detached = false
|
|
118
|
+
|
|
119
|
+
# Parse the third part if present
|
|
120
|
+
if parts.length >= 3
|
|
121
|
+
third_part = parts[2]
|
|
122
|
+
|
|
123
|
+
if third_part.include?("[") && third_part.include?("]")
|
|
124
|
+
# Branch worktree: [branch-name]
|
|
125
|
+
branch_match = third_part.match(/\[([^\]]+)\]/)
|
|
126
|
+
branch = branch_match[1] if branch_match
|
|
127
|
+
bare = third_part.include?("bare")
|
|
128
|
+
elsif third_part.include?("detached HEAD")
|
|
129
|
+
# Detached HEAD worktree
|
|
130
|
+
detached = true
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Try to extract task ID from path or branch
|
|
135
|
+
task_id = extract_task_id(path, branch)
|
|
136
|
+
|
|
137
|
+
new(
|
|
138
|
+
path: path,
|
|
139
|
+
branch: branch,
|
|
140
|
+
commit: commit,
|
|
141
|
+
task_id: task_id,
|
|
142
|
+
bare: bare,
|
|
143
|
+
detached: detached
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Parse multiple lines from git worktree list output
|
|
148
|
+
#
|
|
149
|
+
# @param output [String] Full output from `git worktree list --porcelain`
|
|
150
|
+
# @return [Array<WorktreeInfo>] Array of parsed worktree info
|
|
151
|
+
#
|
|
152
|
+
# @example
|
|
153
|
+
# worktrees = WorktreeInfo.from_git_output_list(`git worktree list --porcelain`)
|
|
154
|
+
def self.from_git_output_list(output)
|
|
155
|
+
return [] if output.nil? || output.empty?
|
|
156
|
+
|
|
157
|
+
# Split by blank lines to get per-worktree blocks
|
|
158
|
+
blocks = output.strip.split(/\n\n+/)
|
|
159
|
+
worktrees = []
|
|
160
|
+
|
|
161
|
+
blocks.each do |block|
|
|
162
|
+
lines = block.strip.split("\n").map(&:strip)
|
|
163
|
+
next unless lines.first&.start_with?("worktree ")
|
|
164
|
+
|
|
165
|
+
path = lines.first.sub(/^worktree\s+/, "")
|
|
166
|
+
commit = nil
|
|
167
|
+
branch = nil
|
|
168
|
+
detached = false
|
|
169
|
+
bare = false
|
|
170
|
+
|
|
171
|
+
lines[1..].each do |line|
|
|
172
|
+
if line.start_with?("HEAD ")
|
|
173
|
+
commit = line.sub(/^HEAD\s+/, "")
|
|
174
|
+
elsif line.start_with?("branch ")
|
|
175
|
+
branch_ref = line.sub(/^branch\s+/, "")
|
|
176
|
+
if branch_ref.start_with?("refs/heads/")
|
|
177
|
+
branch = branch_ref.sub(/^refs\/heads\//, "")
|
|
178
|
+
end
|
|
179
|
+
elsif line == "detached"
|
|
180
|
+
detached = true
|
|
181
|
+
elsif line == "bare"
|
|
182
|
+
bare = true
|
|
183
|
+
end
|
|
184
|
+
# skip: locked, prunable, empty lines
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Detached if no branch line was found and not bare
|
|
188
|
+
detached = true if branch.nil? && !bare && commit
|
|
189
|
+
|
|
190
|
+
task_id = extract_task_id(path, branch)
|
|
191
|
+
worktrees << new(path: path, branch: branch, commit: commit,
|
|
192
|
+
task_id: task_id, bare: bare, detached: detached)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
worktrees
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Find worktree info by task ID
|
|
199
|
+
#
|
|
200
|
+
# @param worktrees [Array<WorktreeInfo>] List of worktree info
|
|
201
|
+
# @param task_id [String] Task ID to search for
|
|
202
|
+
# @return [WorktreeInfo, nil] Matching worktree info or nil
|
|
203
|
+
def self.find_by_task_id(worktrees, task_id)
|
|
204
|
+
worktrees.find { |worktree| worktree.task_id == task_id.to_s }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Find worktree info by directory name
|
|
208
|
+
#
|
|
209
|
+
# @param worktrees [Array<WorktreeInfo>] List of worktree info
|
|
210
|
+
# @param directory [String] Directory name to search for
|
|
211
|
+
# @return [WorktreeInfo, nil] Matching worktree info or nil
|
|
212
|
+
def self.find_by_directory(worktrees, directory)
|
|
213
|
+
worktrees.find { |worktree| worktree.directory_name == directory.to_s }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Find worktree info by branch name
|
|
217
|
+
#
|
|
218
|
+
# @param worktrees [Array<WorktreeInfo>] List of worktree info
|
|
219
|
+
# @param branch [String] Branch name to search for
|
|
220
|
+
# @return [WorktreeInfo, nil] Matching worktree info or nil
|
|
221
|
+
def self.find_by_branch(worktrees, branch)
|
|
222
|
+
worktrees.find { |worktree| worktree.branch == branch.to_s }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Convert to hash
|
|
226
|
+
#
|
|
227
|
+
# @return [Hash] Worktree info as hash
|
|
228
|
+
def to_h
|
|
229
|
+
{
|
|
230
|
+
path: @path,
|
|
231
|
+
branch: @branch,
|
|
232
|
+
commit: @commit,
|
|
233
|
+
task_id: @task_id,
|
|
234
|
+
bare: @bare,
|
|
235
|
+
detached: @detached,
|
|
236
|
+
created_at: @created_at,
|
|
237
|
+
usable: usable?,
|
|
238
|
+
task_associated: task_associated?,
|
|
239
|
+
exists: exists?,
|
|
240
|
+
empty: empty?
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Convert to JSON
|
|
245
|
+
#
|
|
246
|
+
# @return [String] JSON representation
|
|
247
|
+
def to_json(*args)
|
|
248
|
+
to_h.to_json(*args)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Equality comparison
|
|
252
|
+
#
|
|
253
|
+
# @param other [WorktreeInfo] Other worktree info
|
|
254
|
+
# @return [Boolean] true if equal
|
|
255
|
+
def ==(other)
|
|
256
|
+
return false unless other.is_a?(WorktreeInfo)
|
|
257
|
+
|
|
258
|
+
@path == other.path &&
|
|
259
|
+
@branch == other.branch &&
|
|
260
|
+
@commit == other.commit &&
|
|
261
|
+
@task_id == other.task_id
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
alias_method :eql?, :==
|
|
265
|
+
|
|
266
|
+
# Hash for using as hash keys
|
|
267
|
+
#
|
|
268
|
+
# @return [Integer] Hash value
|
|
269
|
+
def hash
|
|
270
|
+
[@path, @branch, @commit, @task_id].hash
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
# Extract task ID from path or branch name
|
|
276
|
+
#
|
|
277
|
+
# @param path [String] Worktree path
|
|
278
|
+
# @param branch [String, nil] Branch name
|
|
279
|
+
# @return [String, nil] Extracted task ID or nil
|
|
280
|
+
def self.extract_task_id(path, branch)
|
|
281
|
+
# Try to extract from path first (e.g., task.081, 081-work)
|
|
282
|
+
path_task_id = extract_task_id_from_string(File.basename(path))
|
|
283
|
+
return path_task_id if path_task_id
|
|
284
|
+
|
|
285
|
+
# Try to extract from branch name (e.g., 081-fix-something)
|
|
286
|
+
extract_task_id_from_string(branch)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Extract task ID from a string using common patterns
|
|
290
|
+
#
|
|
291
|
+
# @param string [String, nil] String to search
|
|
292
|
+
# @return [String, nil] Extracted task ID or nil (preserves subtask IDs like "121.01")
|
|
293
|
+
def self.extract_task_id_from_string(string)
|
|
294
|
+
return nil if string.nil? || string.empty?
|
|
295
|
+
|
|
296
|
+
# Use shared extractor that preserves subtask IDs (e.g., "121.01")
|
|
297
|
+
Atoms::TaskIDExtractor.normalize(string)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Worktree
|
|
6
|
+
module Models
|
|
7
|
+
# Worktree metadata model for task frontmatter
|
|
8
|
+
#
|
|
9
|
+
# Represents worktree information that gets added to task frontmatter
|
|
10
|
+
# to track the association between tasks and their worktrees.
|
|
11
|
+
#
|
|
12
|
+
# @example Create for a new worktree
|
|
13
|
+
# metadata = WorktreeMetadata.new(
|
|
14
|
+
# branch: "081-fix-authentication-bug",
|
|
15
|
+
# path: ".ace-wt/task.081",
|
|
16
|
+
# created_at: Time.now
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example Load from task frontmatter
|
|
20
|
+
# metadata = WorktreeMetadata.from_task_data(task_frontmatter_hash)
|
|
21
|
+
class WorktreeMetadata
|
|
22
|
+
attr_reader :branch, :path, :target_branch, :created_at, :updated_at
|
|
23
|
+
|
|
24
|
+
# Initialize a new WorktreeMetadata
|
|
25
|
+
#
|
|
26
|
+
# @param branch [String] Git branch name
|
|
27
|
+
# @param path [String] Worktree path (relative to project root)
|
|
28
|
+
# @param target_branch [String, nil] PR target branch (default: nil for main)
|
|
29
|
+
# @param created_at [Time] When the worktree was created
|
|
30
|
+
# @param updated_at [Time] When the worktree was last updated
|
|
31
|
+
def initialize(branch:, path:, target_branch: nil, created_at: Time.now, updated_at: Time.now)
|
|
32
|
+
@branch = branch.to_s
|
|
33
|
+
@path = path.to_s
|
|
34
|
+
@target_branch = target_branch&.to_s
|
|
35
|
+
@created_at = created_at.is_a?(Time) ? created_at : Time.parse(created_at.to_s)
|
|
36
|
+
@updated_at = updated_at.is_a?(Time) ? updated_at : Time.parse(updated_at.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Update the metadata with new information
|
|
40
|
+
#
|
|
41
|
+
# @param branch [String, nil] New branch name
|
|
42
|
+
# @param path [String, nil] New path
|
|
43
|
+
# @return [WorktreeMetadata] Updated metadata
|
|
44
|
+
def update(branch: nil, path: nil)
|
|
45
|
+
WorktreeMetadata.new(
|
|
46
|
+
branch: branch || @branch,
|
|
47
|
+
path: path || @path,
|
|
48
|
+
target_branch: @target_branch,
|
|
49
|
+
created_at: @created_at,
|
|
50
|
+
updated_at: Time.now
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check if the worktree is recent (created within last 7 days)
|
|
55
|
+
#
|
|
56
|
+
# @return [Boolean] true if worktree is recent
|
|
57
|
+
def recent?
|
|
58
|
+
@created_at > (Time.now - 7 * 24 * 60 * 60)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check if the worktree is stale (not updated in last 30 days)
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] true if worktree is stale
|
|
64
|
+
def stale?
|
|
65
|
+
@updated_at < (Time.now - 30 * 24 * 60 * 60)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get the age of the worktree in days
|
|
69
|
+
#
|
|
70
|
+
# @return [Float] Age in days
|
|
71
|
+
def age_days
|
|
72
|
+
(Time.now - @created_at) / (24 * 60 * 60)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get the time since last update in days
|
|
76
|
+
#
|
|
77
|
+
# @return [Float] Days since last update
|
|
78
|
+
def days_since_update
|
|
79
|
+
(Time.now - @updated_at) / (24 * 60 * 60)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Convert to hash for YAML serialization
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash] Hash representation
|
|
85
|
+
def to_h
|
|
86
|
+
{
|
|
87
|
+
"branch" => @branch,
|
|
88
|
+
"path" => @path,
|
|
89
|
+
"created_at" => @created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
90
|
+
"updated_at" => @updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
91
|
+
}.tap do |hash|
|
|
92
|
+
hash["target_branch"] = @target_branch if @target_branch
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Convert to YAML string
|
|
97
|
+
#
|
|
98
|
+
# @return [String] YAML representation
|
|
99
|
+
def to_yaml
|
|
100
|
+
to_h.to_yaml
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Load from task frontmatter hash
|
|
104
|
+
#
|
|
105
|
+
# @param task_data [Hash] Task frontmatter data
|
|
106
|
+
# @return [WorktreeMetadata, nil] Worktree metadata or nil if not found
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# metadata = WorktreeMetadata.from_task_data({
|
|
110
|
+
# "worktree" => {
|
|
111
|
+
# "branch" => "081-fix-auth",
|
|
112
|
+
# "path" => ".ace-wt/task.081",
|
|
113
|
+
# "target_branch" => "080-parent-branch",
|
|
114
|
+
# "created_at" => "2025-11-04 13:45:00"
|
|
115
|
+
# }
|
|
116
|
+
# })
|
|
117
|
+
def self.from_task_data(task_data)
|
|
118
|
+
worktree_data = task_data["worktree"]
|
|
119
|
+
return nil unless worktree_data.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
branch = worktree_data["branch"]
|
|
122
|
+
path = worktree_data["path"]
|
|
123
|
+
target_branch = worktree_data["target_branch"]
|
|
124
|
+
return nil unless branch && path
|
|
125
|
+
|
|
126
|
+
created_at = parse_time(worktree_data["created_at"]) || Time.now
|
|
127
|
+
updated_at = parse_time(worktree_data["updated_at"]) || created_at
|
|
128
|
+
|
|
129
|
+
new(
|
|
130
|
+
branch: branch,
|
|
131
|
+
path: path,
|
|
132
|
+
target_branch: target_branch,
|
|
133
|
+
created_at: created_at,
|
|
134
|
+
updated_at: updated_at
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Create from a worktree info object
|
|
139
|
+
#
|
|
140
|
+
# @param worktree_info [WorktreeInfo] Worktree information
|
|
141
|
+
# @param project_root [String] Project root directory
|
|
142
|
+
# @return [WorktreeMetadata] Worktree metadata
|
|
143
|
+
#
|
|
144
|
+
# @example
|
|
145
|
+
# metadata = WorktreeMetadata.from_worktree_info(worktree_info, "/project")
|
|
146
|
+
def self.from_worktree_info(worktree_info, project_root = Dir.pwd)
|
|
147
|
+
require_relative "../atoms/path_expander"
|
|
148
|
+
|
|
149
|
+
# Make path relative to project root
|
|
150
|
+
relative_path = begin
|
|
151
|
+
Atoms::PathExpander.relative_to_git_root(worktree_info.path, project_root)
|
|
152
|
+
rescue
|
|
153
|
+
worktree_info.path
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
new(
|
|
157
|
+
branch: worktree_info.branch,
|
|
158
|
+
path: relative_path,
|
|
159
|
+
created_at: worktree_info.created_at || Time.now
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Merge worktree metadata into task frontmatter
|
|
164
|
+
#
|
|
165
|
+
# @param task_data [Hash] Existing task frontmatter
|
|
166
|
+
# @param worktree_metadata [WorktreeMetadata] Worktree metadata to merge
|
|
167
|
+
# @return [Hash] Updated task frontmatter
|
|
168
|
+
#
|
|
169
|
+
# @example
|
|
170
|
+
# updated_data = WorktreeMetadata.merge_into_task_data(
|
|
171
|
+
# existing_task_data,
|
|
172
|
+
# worktree_metadata
|
|
173
|
+
# )
|
|
174
|
+
def self.merge_into_task_data(task_data, worktree_metadata)
|
|
175
|
+
updated_data = task_data.dup
|
|
176
|
+
updated_data["worktree"] = worktree_metadata.to_h
|
|
177
|
+
updated_data
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Remove worktree metadata from task frontmatter
|
|
181
|
+
#
|
|
182
|
+
# @param task_data [Hash] Task frontmatter
|
|
183
|
+
# @return [Hash] Task frontmatter without worktree metadata
|
|
184
|
+
#
|
|
185
|
+
# @example
|
|
186
|
+
# clean_data = WorktreeMetadata.remove_from_task_data(task_data)
|
|
187
|
+
def self.remove_from_task_data(task_data)
|
|
188
|
+
updated_data = task_data.dup
|
|
189
|
+
updated_data.delete("worktree")
|
|
190
|
+
updated_data
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Check if task has worktree metadata
|
|
194
|
+
#
|
|
195
|
+
# @param task_data [Hash] Task frontmatter
|
|
196
|
+
# @return [Boolean] true if worktree metadata is present
|
|
197
|
+
#
|
|
198
|
+
# @example
|
|
199
|
+
# has_worktree = WorktreeMetadata.present_in_task?(task_data)
|
|
200
|
+
def self.present_in_task?(task_data)
|
|
201
|
+
worktree_data = task_data["worktree"]
|
|
202
|
+
worktree_data.is_a?(Hash) &&
|
|
203
|
+
worktree_data["branch"] &&
|
|
204
|
+
worktree_data["path"]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Find worktree metadata by branch name
|
|
208
|
+
#
|
|
209
|
+
# @param tasks [Array<Hash>] Array of task frontmatter hashes
|
|
210
|
+
# @param branch [String] Branch name to search for
|
|
211
|
+
# @return [WorktreeMetadata, nil] Matching metadata or nil
|
|
212
|
+
#
|
|
213
|
+
# @example
|
|
214
|
+
# metadata = WorktreeMetadata.find_by_branch(tasks, "081-fix-auth")
|
|
215
|
+
def self.find_by_branch(tasks, branch)
|
|
216
|
+
tasks.each do |task_data|
|
|
217
|
+
metadata = from_task_data(task_data)
|
|
218
|
+
return metadata if metadata && metadata.branch == branch
|
|
219
|
+
end
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Find worktree metadata by path
|
|
224
|
+
#
|
|
225
|
+
# @param tasks [Array<Hash>] Array of task frontmatter hashes
|
|
226
|
+
# @param path [String] Path to search for
|
|
227
|
+
# @return [WorktreeMetadata, nil] Matching metadata or nil
|
|
228
|
+
#
|
|
229
|
+
# @example
|
|
230
|
+
# metadata = WorktreeMetadata.find_by_path(tasks, ".ace-wt/task.081")
|
|
231
|
+
def self.find_by_path(tasks, path)
|
|
232
|
+
tasks.each do |task_data|
|
|
233
|
+
metadata = from_task_data(task_data)
|
|
234
|
+
return metadata if metadata && metadata.path == path
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Equality comparison
|
|
240
|
+
#
|
|
241
|
+
# @param other [WorktreeMetadata] Other worktree metadata
|
|
242
|
+
# @return [Boolean] true if equal
|
|
243
|
+
def ==(other)
|
|
244
|
+
return false unless other.is_a?(WorktreeMetadata)
|
|
245
|
+
|
|
246
|
+
@branch == other.branch && @path == other.path
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
alias_method :eql?, :==
|
|
250
|
+
|
|
251
|
+
# Hash for using as hash keys
|
|
252
|
+
#
|
|
253
|
+
# @return [Integer] Hash value
|
|
254
|
+
def hash
|
|
255
|
+
[@branch, @path].hash
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# String representation
|
|
259
|
+
#
|
|
260
|
+
# @return [String] String representation
|
|
261
|
+
def to_s
|
|
262
|
+
"#{@branch} at #{@path}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Inspect representation
|
|
266
|
+
#
|
|
267
|
+
# @return [String] Detailed inspect string
|
|
268
|
+
def inspect
|
|
269
|
+
"#<#{self.class.name} branch=#{@branch.inspect} path=#{@path.inspect} created=#{@created_at}>"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
# Parse time from various formats
|
|
275
|
+
#
|
|
276
|
+
# @param time_input [String, Time, nil] Time input
|
|
277
|
+
# @return [Time, nil] Parsed time or nil
|
|
278
|
+
def self.parse_time(time_input)
|
|
279
|
+
return nil if time_input.nil?
|
|
280
|
+
|
|
281
|
+
case time_input
|
|
282
|
+
when Time
|
|
283
|
+
time_input
|
|
284
|
+
when String
|
|
285
|
+
Time.parse(time_input)
|
|
286
|
+
end
|
|
287
|
+
rescue ArgumentError
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|