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,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
|