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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git/worktree.yml +250 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +19 -0
  4. data/CHANGELOG.md +957 -0
  5. data/LICENSE +21 -0
  6. data/README.md +40 -0
  7. data/Rakefile +14 -0
  8. data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
  9. data/docs/demo/ace-git-worktree-getting-started.tape.yml +28 -0
  10. data/docs/demo/fixtures/README.md +3 -0
  11. data/docs/demo/fixtures/sample.txt +1 -0
  12. data/docs/getting-started.md +114 -0
  13. data/docs/handbook.md +38 -0
  14. data/docs/usage.md +334 -0
  15. data/exe/ace-git-worktree +24 -0
  16. data/handbook/agents/worktree.ag.md +189 -0
  17. data/handbook/skills/as-git-worktree/SKILL.md +27 -0
  18. data/handbook/skills/as-git-worktree-create/SKILL.md +21 -0
  19. data/handbook/skills/as-git-worktree-manage/SKILL.md +20 -0
  20. data/handbook/workflow-instructions/git/worktree-create.wf.md +262 -0
  21. data/handbook/workflow-instructions/git/worktree-manage.wf.md +384 -0
  22. data/handbook/workflow-instructions/git/worktree.wf.md +224 -0
  23. data/lib/ace/git/worktree/atoms/git_command.rb +121 -0
  24. data/lib/ace/git/worktree/atoms/path_expander.rb +189 -0
  25. data/lib/ace/git/worktree/atoms/slug_generator.rb +235 -0
  26. data/lib/ace/git/worktree/atoms/task_id_extractor.rb +91 -0
  27. data/lib/ace/git/worktree/cli/commands/config.rb +50 -0
  28. data/lib/ace/git/worktree/cli/commands/create.rb +80 -0
  29. data/lib/ace/git/worktree/cli/commands/list.rb +76 -0
  30. data/lib/ace/git/worktree/cli/commands/prune.rb +43 -0
  31. data/lib/ace/git/worktree/cli/commands/remove.rb +48 -0
  32. data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +66 -0
  33. data/lib/ace/git/worktree/cli/commands/switch.rb +44 -0
  34. data/lib/ace/git/worktree/cli.rb +103 -0
  35. data/lib/ace/git/worktree/commands/config_command.rb +351 -0
  36. data/lib/ace/git/worktree/commands/create_command.rb +961 -0
  37. data/lib/ace/git/worktree/commands/list_command.rb +247 -0
  38. data/lib/ace/git/worktree/commands/prune_command.rb +260 -0
  39. data/lib/ace/git/worktree/commands/remove_command.rb +522 -0
  40. data/lib/ace/git/worktree/commands/switch_command.rb +249 -0
  41. data/lib/ace/git/worktree/configuration.rb +167 -0
  42. data/lib/ace/git/worktree/models/worktree_config.rb +502 -0
  43. data/lib/ace/git/worktree/models/worktree_info.rb +303 -0
  44. data/lib/ace/git/worktree/models/worktree_metadata.rb +294 -0
  45. data/lib/ace/git/worktree/molecules/config_loader.rb +125 -0
  46. data/lib/ace/git/worktree/molecules/current_task_linker.rb +136 -0
  47. data/lib/ace/git/worktree/molecules/hook_executor.rb +361 -0
  48. data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +186 -0
  49. data/lib/ace/git/worktree/molecules/pr_creator.rb +253 -0
  50. data/lib/ace/git/worktree/molecules/task_committer.rb +329 -0
  51. data/lib/ace/git/worktree/molecules/task_fetcher.rb +244 -0
  52. data/lib/ace/git/worktree/molecules/task_pusher.rb +183 -0
  53. data/lib/ace/git/worktree/molecules/task_status_updater.rb +447 -0
  54. data/lib/ace/git/worktree/molecules/worktree_creator.rb +832 -0
  55. data/lib/ace/git/worktree/molecules/worktree_lister.rb +337 -0
  56. data/lib/ace/git/worktree/molecules/worktree_remover.rb +416 -0
  57. data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +906 -0
  58. data/lib/ace/git/worktree/organisms/worktree_manager.rb +714 -0
  59. data/lib/ace/git/worktree/version.rb +9 -0
  60. data/lib/ace/git/worktree.rb +215 -0
  61. 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