ocak 0.1.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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +268 -0
  4. data/bin/ocak +7 -0
  5. data/lib/ocak/agent_generator.rb +171 -0
  6. data/lib/ocak/claude_runner.rb +169 -0
  7. data/lib/ocak/cli.rb +28 -0
  8. data/lib/ocak/commands/audit.rb +25 -0
  9. data/lib/ocak/commands/clean.rb +30 -0
  10. data/lib/ocak/commands/debt.rb +21 -0
  11. data/lib/ocak/commands/design.rb +34 -0
  12. data/lib/ocak/commands/init.rb +212 -0
  13. data/lib/ocak/commands/resume.rb +128 -0
  14. data/lib/ocak/commands/run.rb +60 -0
  15. data/lib/ocak/commands/status.rb +102 -0
  16. data/lib/ocak/config.rb +109 -0
  17. data/lib/ocak/issue_fetcher.rb +137 -0
  18. data/lib/ocak/logger.rb +192 -0
  19. data/lib/ocak/merge_manager.rb +158 -0
  20. data/lib/ocak/pipeline_runner.rb +389 -0
  21. data/lib/ocak/pipeline_state.rb +51 -0
  22. data/lib/ocak/planner.rb +68 -0
  23. data/lib/ocak/process_runner.rb +82 -0
  24. data/lib/ocak/stack_detector.rb +333 -0
  25. data/lib/ocak/stream_parser.rb +189 -0
  26. data/lib/ocak/templates/agents/auditor.md.erb +87 -0
  27. data/lib/ocak/templates/agents/documenter.md.erb +67 -0
  28. data/lib/ocak/templates/agents/implementer.md.erb +154 -0
  29. data/lib/ocak/templates/agents/merger.md.erb +97 -0
  30. data/lib/ocak/templates/agents/pipeline.md.erb +126 -0
  31. data/lib/ocak/templates/agents/planner.md.erb +86 -0
  32. data/lib/ocak/templates/agents/reviewer.md.erb +98 -0
  33. data/lib/ocak/templates/agents/security_reviewer.md.erb +112 -0
  34. data/lib/ocak/templates/gitignore_additions.txt +10 -0
  35. data/lib/ocak/templates/hooks/post_edit_lint.sh.erb +57 -0
  36. data/lib/ocak/templates/hooks/task_completed_test.sh.erb +34 -0
  37. data/lib/ocak/templates/ocak.yml.erb +99 -0
  38. data/lib/ocak/templates/skills/audit/SKILL.md.erb +132 -0
  39. data/lib/ocak/templates/skills/debt/SKILL.md.erb +128 -0
  40. data/lib/ocak/templates/skills/design/SKILL.md.erb +131 -0
  41. data/lib/ocak/templates/skills/scan_file/SKILL.md.erb +113 -0
  42. data/lib/ocak/verification.rb +83 -0
  43. data/lib/ocak/worktree_manager.rb +92 -0
  44. data/lib/ocak.rb +13 -0
  45. metadata +115 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Ocak
6
+ class Config
7
+ CONFIG_FILE = 'ocak.yml'
8
+
9
+ attr_reader :project_dir
10
+
11
+ def self.load(dir = Dir.pwd)
12
+ path = File.join(dir, CONFIG_FILE)
13
+ raise ConfigNotFound, "No ocak.yml found in #{dir}. Run `ocak init` first." unless File.exist?(path)
14
+
15
+ new(YAML.safe_load_file(path, symbolize_names: true), dir)
16
+ end
17
+
18
+ def initialize(data, project_dir = Dir.pwd)
19
+ @data = data || {}
20
+ @project_dir = project_dir
21
+ @overrides = {}
22
+ validate!
23
+ end
24
+
25
+ def override(key, value)
26
+ @overrides[key] = value
27
+ end
28
+
29
+ # Stack
30
+ def language = dig(:stack, :language) || 'unknown'
31
+ def framework = dig(:stack, :framework)
32
+ def test_command = dig(:stack, :test_command)
33
+ def lint_command = dig(:stack, :lint_command)
34
+ def format_command = dig(:stack, :format_command)
35
+ def setup_command = dig(:stack, :setup_command)
36
+
37
+ # Returns the lint command with auto-fix flags stripped, suitable for check-only verification.
38
+ def lint_check_command
39
+ cmd = lint_command
40
+ return nil unless cmd
41
+
42
+ cmd.gsub(/\s+(?:-A|--fix|--write|--allow-dirty)\b/, '').strip
43
+ end
44
+
45
+ def security_commands
46
+ dig(:stack, :security_commands) || []
47
+ end
48
+
49
+ # Pipeline
50
+ def max_parallel = @overrides[:max_parallel] || dig(:pipeline, :max_parallel) || 3
51
+ def poll_interval = @overrides[:poll_interval] || dig(:pipeline, :poll_interval) || 60
52
+ def worktree_dir = dig(:pipeline, :worktree_dir) || '.claude/worktrees'
53
+ def log_dir = dig(:pipeline, :log_dir) || 'logs/pipeline'
54
+ def cost_budget = dig(:pipeline, :cost_budget)
55
+
56
+ # Safety
57
+ def allowed_authors = dig(:safety, :allowed_authors) || []
58
+ def require_comment = dig(:safety, :require_comment)
59
+ def max_issues_per_run = dig(:safety, :max_issues_per_run) || 5
60
+
61
+ # Labels
62
+ def label_ready = dig(:labels, :ready) || 'auto-ready'
63
+ def label_in_progress = dig(:labels, :in_progress) || 'in-progress'
64
+ def label_completed = dig(:labels, :completed) || 'completed'
65
+ def label_failed = dig(:labels, :failed) || 'pipeline-failed'
66
+
67
+ # Steps
68
+ def steps
69
+ @data[:steps] || default_steps
70
+ end
71
+
72
+ # Agent paths
73
+ def agent_path(name)
74
+ custom = dig(:agents, name.to_sym)
75
+ return File.join(@project_dir, custom) if custom
76
+
77
+ File.join(@project_dir, '.claude', 'agents', "#{name.to_s.tr('_', '-')}.md")
78
+ end
79
+
80
+ private
81
+
82
+ def dig(*keys)
83
+ keys.reduce(@data) { |h, k| h.is_a?(Hash) ? h[k] : nil }
84
+ end
85
+
86
+ def validate!
87
+ return if @data.is_a?(Hash)
88
+
89
+ raise ConfigError, 'ocak.yml must be a YAML hash'
90
+ end
91
+
92
+ def default_steps
93
+ [
94
+ { agent: 'implementer', role: 'implement' },
95
+ { agent: 'reviewer', role: 'review' },
96
+ { agent: 'implementer', role: 'fix', condition: 'has_findings' },
97
+ { agent: 'reviewer', role: 'verify', condition: 'had_fixes' },
98
+ { agent: 'security_reviewer', role: 'security' },
99
+ { agent: 'implementer', role: 'fix', condition: 'has_findings', complexity: 'full' },
100
+ { agent: 'documenter', role: 'document', complexity: 'full' },
101
+ { agent: 'auditor', role: 'audit', complexity: 'full' },
102
+ { agent: 'merger', role: 'merge' }
103
+ ]
104
+ end
105
+
106
+ class ConfigNotFound < StandardError; end
107
+ class ConfigError < StandardError; end
108
+ end
109
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ module Ocak
7
+ class IssueFetcher
8
+ def initialize(config:, logger: nil)
9
+ @config = config
10
+ @logger = logger
11
+ end
12
+
13
+ def fetch_ready
14
+ stdout, _, status = Open3.capture3(
15
+ 'gh', 'issue', 'list',
16
+ '--label', @config.label_ready,
17
+ '--state', 'open',
18
+ '--json', 'number,title,body,labels,author',
19
+ '--limit', '50',
20
+ chdir: @config.project_dir
21
+ )
22
+ return [] unless status.success?
23
+
24
+ issues = JSON.parse(stdout)
25
+ issues.reject! { |i| in_progress?(i) }
26
+ issues.select! { |i| authorized_issue?(i) }
27
+ issues
28
+ rescue JSON::ParserError
29
+ []
30
+ end
31
+
32
+ def add_label(issue_number, label)
33
+ run_gh('issue', 'edit', issue_number.to_s, '--add-label', label)
34
+ end
35
+
36
+ def remove_label(issue_number, label)
37
+ run_gh('issue', 'edit', issue_number.to_s, '--remove-label', label)
38
+ end
39
+
40
+ def transition(issue_number, from:, to:)
41
+ remove_label(issue_number, from) if from
42
+ add_label(issue_number, to)
43
+ end
44
+
45
+ def comment(issue_number, body)
46
+ run_gh('issue', 'comment', issue_number.to_s, '--body', body)
47
+ end
48
+
49
+ def view(issue_number, fields: 'number,title,body,labels')
50
+ stdout, _, status = Open3.capture3(
51
+ 'gh', 'issue', 'view', issue_number.to_s,
52
+ '--json', fields,
53
+ chdir: @config.project_dir
54
+ )
55
+ return nil unless status.success?
56
+
57
+ JSON.parse(stdout)
58
+ rescue JSON::ParserError
59
+ nil
60
+ end
61
+
62
+ private
63
+
64
+ def in_progress?(issue)
65
+ issue['labels']&.any? { |l| l['name'] == @config.label_in_progress }
66
+ end
67
+
68
+ def authorized_issue?(issue)
69
+ authors = allowed_authors
70
+ author_login = issue.dig('author', 'login')
71
+
72
+ return true if authors.empty? && @config.allowed_authors.any?
73
+
74
+ if authors.any? && authors.include?(author_login)
75
+ check_comment_requirement(issue)
76
+ elsif authors.empty?
77
+ # Default: current user only
78
+ if author_login == current_user
79
+ check_comment_requirement(issue)
80
+ else
81
+ @logger&.warn("Skipping issue ##{issue['number']}: author '#{author_login}' not in allowed list")
82
+ false
83
+ end
84
+ else
85
+ @logger&.warn("Skipping issue ##{issue['number']}: author '#{author_login}' not in allowed list")
86
+ false
87
+ end
88
+ end
89
+
90
+ def check_comment_requirement(issue)
91
+ phrase = @config.require_comment
92
+ return true unless phrase
93
+
94
+ # Check if an allowed author commented the required phrase
95
+ comments = fetch_comments(issue['number'])
96
+ authors = allowed_authors.empty? ? [current_user] : allowed_authors
97
+
98
+ has_approval = comments.any? do |c|
99
+ authors.include?(c.dig('author', 'login')) && c['body']&.strip == phrase
100
+ end
101
+
102
+ unless has_approval
103
+ @logger&.warn("Skipping issue ##{issue['number']}: missing required '#{phrase}' comment from allowed author")
104
+ end
105
+
106
+ has_approval
107
+ end
108
+
109
+ def fetch_comments(issue_number)
110
+ stdout, _, status = Open3.capture3(
111
+ 'gh', 'issue', 'view', issue_number.to_s,
112
+ '--json', 'comments',
113
+ chdir: @config.project_dir
114
+ )
115
+ return [] unless status.success?
116
+
117
+ JSON.parse(stdout).fetch('comments', [])
118
+ rescue JSON::ParserError
119
+ []
120
+ end
121
+
122
+ def allowed_authors
123
+ @config.allowed_authors
124
+ end
125
+
126
+ def current_user
127
+ @current_user ||= begin
128
+ stdout, _, status = Open3.capture3('gh', 'api', 'user', '--jq', '.login')
129
+ status.success? ? stdout.strip : nil
130
+ end
131
+ end
132
+
133
+ def run_gh(*)
134
+ Open3.capture3('gh', *, chdir: @config.project_dir)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'fileutils'
5
+
6
+ module Ocak
7
+ class PipelineLogger
8
+ COLORS = {
9
+ reset: "\e[0m",
10
+ bold: "\e[1m",
11
+ dim: "\e[2m",
12
+ red: "\e[31m",
13
+ green: "\e[32m",
14
+ yellow: "\e[33m",
15
+ blue: "\e[34m",
16
+ magenta: "\e[35m",
17
+ cyan: "\e[36m",
18
+ white: "\e[37m"
19
+ }.freeze
20
+
21
+ AGENT_COLORS = {
22
+ 'implementer' => :cyan,
23
+ 'reviewer' => :magenta,
24
+ 'security-reviewer' => :red,
25
+ 'auditor' => :yellow,
26
+ 'documenter' => :blue,
27
+ 'merger' => :green,
28
+ 'planner' => :white,
29
+ 'pipeline' => :cyan
30
+ }.freeze
31
+
32
+ def initialize(log_dir: nil, issue_number: nil, color: $stderr.tty?)
33
+ @color = color
34
+ @mutex = Mutex.new
35
+ @file_logger = setup_file_logger(log_dir, issue_number) if log_dir
36
+ end
37
+
38
+ def info(msg, agent: nil)
39
+ log(:info, msg, agent: agent)
40
+ end
41
+
42
+ def warn(msg, agent: nil)
43
+ log(:warn, msg, agent: agent, color: :yellow)
44
+ end
45
+
46
+ def error(msg, agent: nil)
47
+ log(:error, msg, agent: agent, color: :red)
48
+ end
49
+
50
+ def debug(msg, agent: nil) # rubocop:disable Lint/UnusedMethodArgument
51
+ @file_logger&.debug(msg)
52
+ end
53
+
54
+ attr_reader :log_file_path
55
+
56
+ private
57
+
58
+ def log(level, msg, agent: nil, color: nil)
59
+ ts = Time.now.strftime('%Y-%m-%d %H:%M:%S')
60
+ plain = "[#{ts}] #{level.to_s.upcase}: #{msg}"
61
+
62
+ @file_logger&.send(level, msg)
63
+
64
+ @mutex.synchronize do
65
+ output = @color ? colorize(ts, level, msg, agent: agent, color: color) : plain
66
+ $stderr.write("#{output}\n")
67
+ end
68
+ end
69
+
70
+ def colorize(timestamp, level, msg, agent: nil, color: nil)
71
+ parts = [c(:dim), timestamp, c(:reset), ' ']
72
+
73
+ if agent
74
+ agent_color = AGENT_COLORS.fetch(agent, :white)
75
+ parts.push c(agent_color), c(:bold), "[#{agent}]", c(:reset), ' '
76
+ end
77
+
78
+ msg_color = color || level_color(level)
79
+ parts.push c(msg_color), msg, c(:reset)
80
+ parts.join
81
+ end
82
+
83
+ def level_color(level)
84
+ case level
85
+ when :error then :red
86
+ when :warn then :yellow
87
+ when :info then :white
88
+ else :dim
89
+ end
90
+ end
91
+
92
+ def c(name)
93
+ @color ? COLORS.fetch(name, '') : ''
94
+ end
95
+
96
+ def setup_file_logger(log_dir, issue_number)
97
+ FileUtils.mkdir_p(log_dir)
98
+ timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
99
+ suffix = issue_number ? "issue-#{issue_number}" : 'pipeline'
100
+ @log_file_path = File.join(log_dir, "#{timestamp}-#{suffix}.log")
101
+
102
+ logger = ::Logger.new(@log_file_path)
103
+ logger.formatter = proc do |severity, datetime, _progname, msg|
104
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
105
+ end
106
+ logger
107
+ end
108
+ end
109
+
110
+ # Colorized real-time terminal output for --watch mode.
111
+ class WatchFormatter
112
+ def initialize(io = $stderr)
113
+ @io = io
114
+ @tty = io.respond_to?(:tty?) && io.tty?
115
+ @mutex = Mutex.new
116
+ end
117
+
118
+ def emit(agent_name, event)
119
+ return unless event
120
+
121
+ line = format_event(agent_name, event)
122
+ return unless line
123
+
124
+ @mutex.synchronize { @io.puts line }
125
+ end
126
+
127
+ private
128
+
129
+ def format_event(agent_name, event)
130
+ ts = Time.now.strftime('%H:%M:%S')
131
+ agent_color = PipelineLogger::AGENT_COLORS.fetch(agent_name, :white)
132
+ prefix = "#{c(:dim)}#{ts}#{c(:reset)} #{c(agent_color)}#{c(:bold)}[#{agent_name}]#{c(:reset)}"
133
+
134
+ case event[:category]
135
+ when :init then "#{prefix} #{c(:dim)}Session started (model: #{event[:model]})#{c(:reset)}"
136
+ when :tool_call then format_tool_call(prefix, event)
137
+ when :tool_result then format_tool_result(prefix, event)
138
+ when :text then format_text(prefix, event)
139
+ when :result then format_result(prefix, event)
140
+ end
141
+ end
142
+
143
+ def format_tool_call(prefix, event)
144
+ case event[:tool]
145
+ when 'Edit', 'Write'
146
+ "#{prefix} #{c(:yellow)}[EDIT]#{c(:reset)} #{event[:detail]}"
147
+ when 'Bash'
148
+ "#{prefix} #{c(:blue)}[BASH]#{c(:reset)} #{c(:dim)}#{event[:detail]}#{c(:reset)}"
149
+ when 'Read'
150
+ "#{prefix} #{c(:dim)}[READ] #{event[:detail]}#{c(:reset)}"
151
+ when 'Glob', 'Grep'
152
+ "#{prefix} #{c(:dim)}[#{event[:tool].upcase}] #{event[:detail]}#{c(:reset)}"
153
+ else
154
+ "#{prefix} #{c(:dim)}[#{event[:tool]}]#{c(:reset)}"
155
+ end
156
+ end
157
+
158
+ def format_tool_result(prefix, event)
159
+ return nil unless event[:is_test_result]
160
+
161
+ color = event[:passed] ? :green : :red
162
+ status = event[:passed] ? 'PASS' : 'FAIL'
163
+ "#{prefix} #{c(color)}#{c(:bold)}[TEST #{status}]#{c(:reset)} #{c(:dim)}#{event[:command]}#{c(:reset)}"
164
+ end
165
+
166
+ def format_text(prefix, event)
167
+ return nil unless event[:has_findings]
168
+
169
+ if event[:has_red]
170
+ "#{prefix} #{c(:red)}#{c(:bold)}[REVIEW] BLOCKING#{c(:reset)}"
171
+ elsif event[:has_yellow]
172
+ "#{prefix} #{c(:yellow)}[REVIEW] WARNING#{c(:reset)}"
173
+ else
174
+ "#{prefix} #{c(:green)}[REVIEW] PASS#{c(:reset)}"
175
+ end
176
+ end
177
+
178
+ def format_result(prefix, event)
179
+ cost = event[:cost_usd] ? format('$%.4f', event[:cost_usd]) : 'n/a'
180
+ dur = event[:duration_ms] ? "#{(event[:duration_ms] / 1000.0).round(1)}s" : 'n/a'
181
+ turns = event[:num_turns] ? "#{event[:num_turns]} turns" : ''
182
+ color = event[:subtype] == 'success' ? :green : :red
183
+ done = "#{c(color)}#{c(:bold)}[DONE]#{c(:reset)}"
184
+ detail = "#{c(:dim)}#{event[:subtype]} #{cost} #{dur} #{turns}#{c(:reset)}"
185
+ "#{prefix} #{done} #{detail}"
186
+ end
187
+
188
+ def c(name)
189
+ @tty ? PipelineLogger::COLORS.fetch(name, '') : ''
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ module Ocak
7
+ class MergeManager
8
+ def initialize(config:, claude:, logger:, watch: nil)
9
+ @config = config
10
+ @claude = claude
11
+ @logger = logger
12
+ @watch = watch
13
+ end
14
+
15
+ # Rebase, test, push, then let the merger agent create PR + merge + close issue.
16
+ def merge(issue_number, worktree)
17
+ @logger.info("Starting merge for issue ##{issue_number}")
18
+
19
+ commit_uncommitted_changes(issue_number, worktree)
20
+
21
+ unless rebase_onto_main(worktree)
22
+ @logger.error("Rebase failed for issue ##{issue_number}")
23
+ return false
24
+ end
25
+
26
+ unless verify_tests(worktree)
27
+ @logger.error("Tests failed after rebase for issue ##{issue_number}")
28
+ return false
29
+ end
30
+
31
+ unless push_branch(worktree)
32
+ @logger.error("Push failed for issue ##{issue_number}")
33
+ return false
34
+ end
35
+
36
+ result = @claude.run_agent(
37
+ 'merger',
38
+ "Create a PR, merge it, and close issue ##{issue_number}. Branch: #{worktree.branch}",
39
+ chdir: worktree.path
40
+ )
41
+
42
+ if result.success?
43
+ @logger.info("Issue ##{issue_number} merged successfully")
44
+ true
45
+ else
46
+ @logger.error("Merger agent failed for issue ##{issue_number}")
47
+ false
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def commit_uncommitted_changes(issue_number, worktree)
54
+ stdout, = git('status', '--porcelain', chdir: worktree.path)
55
+ return if stdout.strip.empty?
56
+
57
+ @logger.info('Found uncommitted changes, committing before merge...')
58
+ git('add', '-A', chdir: worktree.path)
59
+ _, stderr, status = git('commit', '-m', "chore: uncommitted pipeline changes for issue ##{issue_number}",
60
+ chdir: worktree.path)
61
+
62
+ if status.success?
63
+ @logger.info('Committed uncommitted changes')
64
+ else
65
+ @logger.warn("Commit of uncommitted changes failed: #{stderr[0..200]}")
66
+ end
67
+ end
68
+
69
+ def rebase_onto_main(worktree)
70
+ git('fetch', 'origin', 'main', chdir: worktree.path)
71
+ _, stderr, status = git('rebase', 'origin/main', chdir: worktree.path)
72
+
73
+ return true if status.success?
74
+
75
+ @logger.warn("Rebase conflict, aborting rebase: #{stderr}")
76
+ git('rebase', '--abort', chdir: worktree.path)
77
+
78
+ # Fall back to merge strategy
79
+ @logger.info('Attempting merge strategy instead...')
80
+ _, merge_stderr, merge_status = git('merge', 'origin/main', '--no-edit', chdir: worktree.path)
81
+
82
+ return true if merge_status.success?
83
+
84
+ # Merge also has conflicts — try to resolve via agent
85
+ @logger.warn("Merge conflict, attempting agent resolution: #{merge_stderr}")
86
+ resolve_conflicts_via_agent(worktree)
87
+ end
88
+
89
+ def resolve_conflicts_via_agent(worktree)
90
+ # Get list of conflicting files
91
+ stdout, = git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
92
+ conflicting = stdout.lines.map(&:strip).reject(&:empty?)
93
+
94
+ if conflicting.empty?
95
+ @logger.warn('No conflicting files found, aborting merge')
96
+ git('merge', '--abort', chdir: worktree.path)
97
+ return false
98
+ end
99
+
100
+ result = @claude.run_agent(
101
+ 'implementer',
102
+ "Resolve these merge conflicts. Conflicting files:\n#{conflicting.join("\n")}\n\n" \
103
+ 'Open each file, find conflict markers (<<<<<<< ======= >>>>>>>), and resolve them. ' \
104
+ 'Then run `git add` on each resolved file.',
105
+ chdir: worktree.path
106
+ )
107
+
108
+ if result.success?
109
+ # Check if all conflicts resolved
110
+ remaining, = git('diff', '--name-only', '--diff-filter=U', chdir: worktree.path)
111
+ if remaining.strip.empty?
112
+ git('commit', '--no-edit', chdir: worktree.path)
113
+ @logger.info('Merge conflicts resolved by agent')
114
+ return true
115
+ end
116
+ end
117
+
118
+ @logger.error('Agent could not resolve merge conflicts')
119
+ git('merge', '--abort', chdir: worktree.path)
120
+ false
121
+ end
122
+
123
+ def verify_tests(worktree)
124
+ test_cmd = @config.test_command
125
+ return true unless test_cmd
126
+
127
+ @logger.info('Running tests after rebase...')
128
+ _, _, status = shell(test_cmd, chdir: worktree.path)
129
+
130
+ if status.success?
131
+ @logger.info('Tests passed after rebase')
132
+ true
133
+ else
134
+ @logger.warn('Tests failed after rebase')
135
+ false
136
+ end
137
+ end
138
+
139
+ def push_branch(worktree)
140
+ _, stderr, status = git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
141
+
142
+ unless status.success?
143
+ @logger.error("Push failed: #{stderr}")
144
+ return false
145
+ end
146
+
147
+ true
148
+ end
149
+
150
+ def git(*, chdir:)
151
+ Open3.capture3('git', *, chdir: chdir)
152
+ end
153
+
154
+ def shell(cmd, chdir:)
155
+ Open3.capture3(*Shellwords.shellsplit(cmd), chdir: chdir)
156
+ end
157
+ end
158
+ end