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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/config"
5
+
6
+ module Ace
7
+ module Git
8
+ module Worktree
9
+ module Molecules
10
+ # Configuration loader molecule
11
+ #
12
+ # Loads and merges worktree configuration using ace-config cascade system.
13
+ # Handles configuration validation and provides access to merged configuration.
14
+ #
15
+ # @example Load configuration for current project
16
+ # loader = ConfigLoader.new(Dir.pwd)
17
+ # config = loader.load
18
+ #
19
+ # @example Load with custom project root
20
+ # loader = ConfigLoader.new("/path/to/project")
21
+ # config = loader.load
22
+ class ConfigLoader
23
+ # Initialize a new ConfigLoader
24
+ #
25
+ # @param project_root [String] Project root directory
26
+ def initialize(project_root = Dir.pwd)
27
+ @project_root = project_root
28
+ end
29
+
30
+ # Load and merge worktree configuration
31
+ #
32
+ # @return [WorktreeConfig] Loaded and validated configuration
33
+ #
34
+ # @example
35
+ # loader = ConfigLoader.new
36
+ # config = loader.load
37
+ # config.root_path # => ".ace-wt"
38
+ # config.mise_trust_auto? # => true
39
+ def load
40
+ # Load configuration using ace-config cascade
41
+ config_hash = load_config
42
+
43
+ # Create configuration object
44
+ config = Models::WorktreeConfig.new(config_hash, @project_root)
45
+
46
+ # Validate configuration
47
+ validate_config(config)
48
+
49
+ config
50
+ end
51
+
52
+ # Load configuration without validation (for testing)
53
+ #
54
+ # @return [WorktreeConfig] Configuration without validation
55
+ def load_without_validation
56
+ config_hash = load_config
57
+ Models::WorktreeConfig.new(config_hash, @project_root)
58
+ end
59
+
60
+ # Check if configuration exists
61
+ #
62
+ # @return [Boolean] true if configuration files exist
63
+ def config_exists?
64
+ # Check for configuration files in expected locations
65
+ config_files.each.any? { |file| File.exist?(file) }
66
+ end
67
+
68
+ # Get list of configuration files that would be checked
69
+ #
70
+ # @return [Array<String>] List of configuration file paths
71
+ def config_files
72
+ [
73
+ File.join(@project_root, ".ace", "git", "worktree.yml"),
74
+ File.join(@project_root, ".ace-defaults", "git", "worktree.yml"),
75
+ File.expand_path("~/.ace/git/worktree.yml")
76
+ ]
77
+ end
78
+
79
+ # Reset configuration cache
80
+ def reset_cache!
81
+ @config_hash = nil
82
+ end
83
+
84
+ private
85
+
86
+ # Load configuration using ace-config cascade
87
+ #
88
+ # @return [Hash] Configuration hash from ace-config
89
+ def load_config
90
+ return @config_hash if @config_hash
91
+
92
+ gem_root = Gem.loaded_specs["ace-git-worktree"]&.gem_dir ||
93
+ File.expand_path("../../../../..", __dir__)
94
+
95
+ resolver = Ace::Support::Config.create(
96
+ config_dir: ".ace",
97
+ defaults_dir: ".ace-defaults",
98
+ gem_path: gem_root
99
+ )
100
+
101
+ # Resolve config for git/worktree namespace
102
+ config = resolver.resolve_namespace("git", filename: "worktree")
103
+
104
+ @config_hash = config.data
105
+ rescue => e
106
+ warn "Warning: Error loading worktree configuration: #{e.message}"
107
+ @config_hash = {}
108
+ end
109
+
110
+ # Validate loaded configuration
111
+ #
112
+ # @param config [WorktreeConfig] Configuration to validate
113
+ # @raise [ArgumentError] If configuration is invalid
114
+ def validate_config(config)
115
+ errors = config.validate
116
+
117
+ if errors.any?
118
+ raise ArgumentError, "Invalid worktree configuration:\n#{errors.map { |e| " - #{e}" }.join("\n")}"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Ace
7
+ module Git
8
+ module Worktree
9
+ module Molecules
10
+ # Creates and manages the _current symlink pointing to the active task directory
11
+ #
12
+ # This molecule provides quick access to the current working task without
13
+ # needing to remember task IDs. The symlink is created at project root.
14
+ #
15
+ # @example Create symlink to task directory
16
+ # linker = CurrentTaskLinker.new(project_root: "/project")
17
+ # result = linker.link("/project/.ace-task/v.0.9.0/tasks/145-feat/")
18
+ # # => { success: true, symlink_path: "/project/_current", target: "..." }
19
+ #
20
+ # @example Remove symlink
21
+ # linker.unlink
22
+ # # => { success: true }
23
+ class CurrentTaskLinker
24
+ # Default name for the symlink
25
+ DEFAULT_SYMLINK_NAME = "_current"
26
+
27
+ # Initialize a new CurrentTaskLinker
28
+ #
29
+ # @param project_root [String, nil] Project root directory (defaults to Dir.pwd)
30
+ # @param symlink_name [String] Name of the symlink (default: "_current")
31
+ def initialize(project_root: nil, symlink_name: DEFAULT_SYMLINK_NAME)
32
+ @project_root = project_root || Dir.pwd
33
+ @symlink_name = symlink_name
34
+ end
35
+
36
+ # Create symlink to task directory
37
+ #
38
+ # Creates a symlink at project root pointing to the given task directory.
39
+ # Uses relative paths for portability. Removes existing symlink if present.
40
+ #
41
+ # @param task_directory [String] Absolute path to task directory
42
+ # @return [Hash] Result with :success, :symlink_path, :target, :relative_target, :error
43
+ def link(task_directory)
44
+ return {success: false, error: "Task directory is required"} if task_directory.nil? || task_directory.empty?
45
+ return {success: false, error: "Task directory does not exist: #{task_directory}"} unless Dir.exist?(task_directory)
46
+
47
+ symlink_path = File.join(@project_root, @symlink_name)
48
+
49
+ # Remove existing symlink or file if present
50
+ remove_existing(symlink_path)
51
+
52
+ # Calculate relative path from project root to task directory
53
+ relative_target = calculate_relative_path(task_directory)
54
+
55
+ # Create the symlink
56
+ File.symlink(relative_target, symlink_path)
57
+
58
+ {
59
+ success: true,
60
+ symlink_path: symlink_path,
61
+ target: task_directory,
62
+ relative_target: relative_target
63
+ }
64
+ rescue => e
65
+ {success: false, error: "Failed to create symlink: #{e.message}"}
66
+ end
67
+
68
+ # Remove the _current symlink
69
+ #
70
+ # @return [Hash] Result with :success, :error
71
+ def unlink
72
+ symlink_path = File.join(@project_root, @symlink_name)
73
+
74
+ return {success: true, existed: false} unless File.symlink?(symlink_path)
75
+
76
+ FileUtils.rm_f(symlink_path)
77
+ {success: true, existed: true}
78
+ rescue => e
79
+ {success: false, error: "Failed to remove symlink: #{e.message}"}
80
+ end
81
+
82
+ # Get the path to the current symlink
83
+ #
84
+ # @return [String] Path to the symlink
85
+ def symlink_path
86
+ File.join(@project_root, @symlink_name)
87
+ end
88
+
89
+ # Check if symlink exists
90
+ #
91
+ # @return [Boolean] true if symlink exists
92
+ def exists?
93
+ File.symlink?(symlink_path)
94
+ end
95
+
96
+ # Get the target of the current symlink
97
+ #
98
+ # @return [String, nil] Target path or nil if symlink doesn't exist
99
+ def current_target
100
+ return nil unless exists?
101
+
102
+ File.readlink(symlink_path)
103
+ end
104
+
105
+ # Get the absolute path to the current task directory
106
+ #
107
+ # @return [String, nil] Absolute path or nil if symlink doesn't exist
108
+ def current_absolute_path
109
+ return nil unless exists?
110
+
111
+ File.realpath(symlink_path)
112
+ rescue Errno::ENOENT
113
+ nil
114
+ end
115
+
116
+ private
117
+
118
+ # Remove existing symlink or file at path
119
+ #
120
+ # @param path [String] Path to remove
121
+ def remove_existing(path)
122
+ FileUtils.rm_f(path) if File.exist?(path) || File.symlink?(path)
123
+ end
124
+
125
+ # Calculate relative path from project root to target
126
+ #
127
+ # @param target [String] Absolute path to target
128
+ # @return [String] Relative path
129
+ def calculate_relative_path(target)
130
+ Pathname.new(target).relative_path_from(Pathname.new(@project_root)).to_s
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Ace
7
+ module Git
8
+ module Worktree
9
+ module Molecules
10
+ # Hook executor molecule
11
+ #
12
+ # Executes after-create hooks defined in YAML configuration.
13
+ # Supports sequential command execution with timeout, error handling,
14
+ # and environment variable interpolation.
15
+ #
16
+ # @example Execute hooks from configuration
17
+ # executor = HookExecutor.new
18
+ # hooks = [
19
+ # { "command" => "mise trust mise.toml", "timeout" => 10 },
20
+ # { "command" => "echo 'Setup complete'" }
21
+ # ]
22
+ # result = executor.execute_hooks(hooks, worktree_path: "/path/to/worktree")
23
+ #
24
+ # @example Hook configuration format
25
+ # hooks:
26
+ # after_create:
27
+ # - command: "mise trust mise*.toml"
28
+ # working_dir: "."
29
+ # timeout: 30
30
+ # continue_on_error: true
31
+ # env:
32
+ # CUSTOM_VAR: "value"
33
+ class HookExecutor
34
+ # Fallback timeout for hook execution (seconds)
35
+ # Used only when config is unavailable
36
+ FALLBACK_DEFAULT_TIMEOUT = 30
37
+
38
+ # Fallback maximum timeout allowed (seconds)
39
+ # Used only when config is unavailable
40
+ FALLBACK_MAX_TIMEOUT = 300
41
+
42
+ # Initialize a new HookExecutor
43
+ def initialize
44
+ @results = []
45
+ end
46
+
47
+ # Get default timeout from config or fallback
48
+ # @return [Integer] Default timeout in seconds
49
+ def default_timeout
50
+ Ace::Git::Worktree.hook_timeout
51
+ rescue
52
+ FALLBACK_DEFAULT_TIMEOUT
53
+ end
54
+
55
+ # Get maximum timeout from config or fallback
56
+ # @return [Integer] Maximum timeout in seconds
57
+ def max_timeout
58
+ Ace::Git::Worktree.max_timeout
59
+ rescue
60
+ FALLBACK_MAX_TIMEOUT
61
+ end
62
+
63
+ # Execute a list of hooks
64
+ #
65
+ # @param hooks [Array<Hash>] Array of hook definitions
66
+ # @param worktree_path [String] Path to the worktree
67
+ # @param task_data [Hash, nil] Optional task data for variable interpolation
68
+ # @param project_root [String] Project root directory (default working dir)
69
+ # @return [Hash] Execution result with :success, :results, :errors
70
+ #
71
+ # @example
72
+ # result = executor.execute_hooks(
73
+ # [{ "command" => "mise trust" }],
74
+ # worktree_path: "/path/to/worktree",
75
+ # project_root: "/path/to/project",
76
+ # task_data: { task_id: "081", title: "Fix bug" }
77
+ # )
78
+ # # => { success: true, results: [...], errors: [] }
79
+ def execute_hooks(hooks, worktree_path:, project_root: Dir.pwd, task_data: nil)
80
+ return success_result if hooks.nil? || hooks.empty?
81
+
82
+ @worktree_path = worktree_path
83
+ @project_root = project_root
84
+ @task_data = task_data || {}
85
+ @results = []
86
+ errors = []
87
+
88
+ hooks.each_with_index do |hook_config, index|
89
+ result = execute_hook(hook_config, index)
90
+ @results << result
91
+
92
+ unless result[:success]
93
+ errors << "Hook #{index + 1}: #{result[:error]}"
94
+ # Stop execution unless continue_on_error is true
95
+ break unless hook_config["continue_on_error"]
96
+ end
97
+ end
98
+
99
+ {
100
+ success: errors.empty?,
101
+ results: @results,
102
+ errors: errors
103
+ }
104
+ rescue => e
105
+ {
106
+ success: false,
107
+ results: @results,
108
+ errors: ["Unexpected error: #{e.message}"]
109
+ }
110
+ end
111
+
112
+ private
113
+
114
+ # Execute a single hook
115
+ #
116
+ # @param hook_config [Hash] Hook configuration
117
+ # @param index [Integer] Hook index for error messages
118
+ # @return [Hash] Execution result
119
+ def execute_hook(hook_config, index)
120
+ command = hook_config["command"]
121
+
122
+ # Validate command
123
+ unless command.is_a?(String) && !command.strip.empty?
124
+ return error_result(
125
+ command: command || "(empty)",
126
+ error: "Command must be a non-empty string"
127
+ )
128
+ end
129
+
130
+ # Interpolate variables
131
+ interpolated_command = interpolate_variables(command)
132
+
133
+ # Determine working directory
134
+ working_dir = resolve_working_dir(hook_config["working_dir"])
135
+
136
+ # Get timeout
137
+ timeout = get_timeout(hook_config["timeout"])
138
+
139
+ # Prepare environment
140
+ env = prepare_environment(hook_config["env"])
141
+
142
+ # Execute command
143
+ execute_command(
144
+ command: interpolated_command,
145
+ working_dir: working_dir,
146
+ timeout: timeout,
147
+ env: env
148
+ )
149
+ rescue => e
150
+ error_result(
151
+ command: command,
152
+ error: e.message
153
+ )
154
+ end
155
+
156
+ # Execute a shell command
157
+ #
158
+ # @param command [String] Command to execute
159
+ # @param working_dir [String] Working directory
160
+ # @param timeout [Integer] Timeout in seconds
161
+ # @param env [Hash] Environment variables
162
+ # @return [Hash] Execution result
163
+ def execute_command(command:, working_dir:, timeout:, env:)
164
+ start_time = Time.now
165
+
166
+ # Use Timeout module to enforce timeout
167
+ stdout, stderr, status = Timeout.timeout(timeout) do
168
+ Open3.capture3(
169
+ env,
170
+ command,
171
+ chdir: working_dir
172
+ )
173
+ end
174
+
175
+ duration = Time.now - start_time
176
+
177
+ if status.success?
178
+ success_result(
179
+ command: command,
180
+ stdout: stdout,
181
+ stderr: stderr,
182
+ duration: duration,
183
+ working_dir: working_dir
184
+ )
185
+ else
186
+ error_result(
187
+ command: command,
188
+ error: "Command failed with exit code #{status.exitstatus}",
189
+ stdout: stdout,
190
+ stderr: stderr,
191
+ duration: duration,
192
+ exit_code: status.exitstatus
193
+ )
194
+ end
195
+ rescue Timeout::Error
196
+ error_result(
197
+ command: command,
198
+ error: "Command timed out after #{timeout} seconds",
199
+ timeout: timeout
200
+ )
201
+ rescue => e
202
+ error_result(
203
+ command: command,
204
+ error: "Execution error: #{e.message}"
205
+ )
206
+ end
207
+
208
+ # Interpolate variables in command string
209
+ #
210
+ # @param command [String] Command with {variable} placeholders
211
+ # @return [String] Command with variables replaced
212
+ def interpolate_variables(command)
213
+ result = command.dup
214
+
215
+ # Worktree variables
216
+ result.gsub!("{worktree_path}", @worktree_path.to_s)
217
+ result.gsub!("{worktree_dir}", File.basename(@worktree_path.to_s))
218
+
219
+ # Task variables (if available)
220
+ if @task_data && !@task_data.empty?
221
+ result.gsub!("{task_id}", extract_task_id(@task_data))
222
+ result.gsub!("{task_title}", @task_data[:title].to_s)
223
+ result.gsub!("{slug}", extract_slug(@task_data))
224
+ end
225
+
226
+ result
227
+ end
228
+
229
+ # Resolve working directory
230
+ #
231
+ # @param working_dir [String, nil] Configured working directory
232
+ # @return [String] Absolute working directory path
233
+ def resolve_working_dir(working_dir)
234
+ # Default to project root if not specified
235
+ return @project_root if working_dir.nil? || working_dir.empty?
236
+
237
+ case working_dir
238
+ when "."
239
+ # "." means current project root
240
+ @project_root
241
+ when "worktree"
242
+ # Special keyword for worktree directory
243
+ @worktree_path
244
+ when /^\//
245
+ # Absolute path
246
+ working_dir
247
+ else
248
+ # Relative path from project root
249
+ File.join(@project_root, working_dir)
250
+ end
251
+ end
252
+
253
+ # Get validated timeout value
254
+ #
255
+ # @param timeout [Integer, nil] Configured timeout
256
+ # @return [Integer] Validated timeout in seconds
257
+ def get_timeout(timeout)
258
+ return default_timeout if timeout.nil?
259
+
260
+ timeout_int = timeout.to_i
261
+ return default_timeout if timeout_int <= 0
262
+
263
+ [timeout_int, max_timeout].min
264
+ end
265
+
266
+ # Prepare environment variables
267
+ #
268
+ # @param env_config [Hash, nil] Environment configuration
269
+ # @return [Hash] Environment hash for Open3
270
+ def prepare_environment(env_config)
271
+ env = {}
272
+
273
+ # Add project environment variables
274
+ env["ACE_PROJECT_ROOT"] = @project_root
275
+
276
+ # Add worktree environment variables
277
+ env["ACE_WORKTREE_PATH"] = @worktree_path
278
+ env["ACE_WORKTREE_DIR"] = File.basename(@worktree_path)
279
+
280
+ # Add task environment variables (if available)
281
+ if @task_data && !@task_data.empty?
282
+ env["ACE_TASK_ID"] = extract_task_id(@task_data)
283
+ env["ACE_TASK_TITLE"] = @task_data[:title].to_s
284
+ end
285
+
286
+ # Add custom environment variables
287
+ if env_config.is_a?(Hash)
288
+ env_config.each do |key, value|
289
+ env[key.to_s] = value.to_s
290
+ end
291
+ end
292
+
293
+ env
294
+ end
295
+
296
+ # Extract task ID from task data
297
+ #
298
+ # @param task_data [Hash] Task data
299
+ # @return [String] Task ID
300
+ def extract_task_id(task_data)
301
+ return task_data[:task_number].to_s if task_data[:task_number]
302
+
303
+ if task_data[:id]
304
+ match = task_data[:id].match(/task\.(\d+)$/)
305
+ return match[1] if match
306
+ end
307
+
308
+ "unknown"
309
+ end
310
+
311
+ # Extract slug from task data
312
+ #
313
+ # @param task_data [Hash] Task data
314
+ # @return [String] URL-safe slug
315
+ def extract_slug(task_data)
316
+ return task_data[:slug].to_s if task_data[:slug]
317
+
318
+ title = task_data[:title].to_s
319
+ return "" if title.empty?
320
+
321
+ # Generate slug from title
322
+ require_relative "../atoms/slug_generator"
323
+ Atoms::SlugGenerator.from_title(title)
324
+ end
325
+
326
+ # Create success result
327
+ #
328
+ # @param details [Hash] Additional details
329
+ # @return [Hash] Success result
330
+ def success_result(**details)
331
+ {
332
+ success: true,
333
+ command: details[:command] || "",
334
+ stdout: details[:stdout] || "",
335
+ stderr: details[:stderr] || "",
336
+ duration: details[:duration] || 0,
337
+ working_dir: details[:working_dir]
338
+ }.compact
339
+ end
340
+
341
+ # Create error result
342
+ #
343
+ # @param details [Hash] Error details
344
+ # @return [Hash] Error result
345
+ def error_result(**details)
346
+ {
347
+ success: false,
348
+ command: details[:command] || "",
349
+ error: details[:error] || "Unknown error",
350
+ stdout: details[:stdout] || "",
351
+ stderr: details[:stderr] || "",
352
+ duration: details[:duration],
353
+ exit_code: details[:exit_code],
354
+ timeout: details[:timeout]
355
+ }.compact
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end