commiti 1.2.3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 55dfe17b32ed45b600920943c16b99f39b0acff0c9c66e516db63e6fa7a0debf
4
+ data.tar.gz: 9f70caaeef73f63211e93bd28ae2ae69236c0e777153c48a765066a0825e4e49
5
+ SHA512:
6
+ metadata.gz: a627866611dcc0974b30a005814b38226833412a1789a57f4f3559c010849313731d361dd0d0e192722577e0f1353d8fa55c2bf9487c4d83bbe596e7d5a04e16
7
+ data.tar.gz: 0b8e9679be21a6f74ea419ffb4968f883fb564990eeab845c63f525190d459a151cebc17d2d852a3eba9f8293093107bd146e76cf37cae8087af88bd06f53e5e
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Setoju
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # Commiti
2
+
3
+ AI-powered commit message and pull request description generator for Git repositories, using Google AI models.
4
+
5
+ ## What It Does
6
+
7
+ Commiti helps you:
8
+
9
+ - Generate conventional commit messages from staged changes.
10
+ - Generate structured pull request descriptions from branch diffs.
11
+ - Review and optionally edit generated commit messages before writing to Git history.
12
+ - Open a prefilled PR/MR page in your browser for GitHub, GitLab, or GitBucket (no provider API token required).
13
+ - Preserve semantic diff quality on large changes using file-aware clipping that keeps file headers and hunk markers.
14
+
15
+ ## Requirements
16
+
17
+ - Ruby 3.2+
18
+ - Git
19
+ - Google AI API key
20
+ - A Git repository as your working directory
21
+
22
+ ## Install Dependencies (from source)
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ ## CLI Usage
29
+
30
+ ```bash
31
+ bundle exec ruby -Ilib bin/commiti [options]
32
+ ```
33
+
34
+ Or after gem installation:
35
+
36
+ ```bash
37
+ commiti [options]
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ Commiti uses a single configuration approach: environment variables.
43
+
44
+ Set variables in your shell, CI secret manager, or local `.env` file (in your project):
45
+
46
+ ```dotenv
47
+ GOOGLE_API_KEY=your_google_ai_key
48
+
49
+ # Optional overrides:
50
+ # COMMITI_MODEL=gemma-4-31b-it
51
+ # COMMITI_CANDIDATES=1
52
+ # COMMITI_BASE_BRANCH=main
53
+ # COMMITI_NO_COPY=false
54
+ ```
55
+
56
+ `GEMINI_API_KEY` is also accepted as an alias for `GOOGLE_API_KEY`.
57
+
58
+ You can copy `.env.example` as a starting point.
59
+
60
+ Your API key is sent directly from your local process to Google's API.
61
+ Commiti does not store it and does not proxy requests through any Commiti server.
62
+ Never commit `.env` to git.
63
+
64
+ ### Options
65
+
66
+ - `--type TYPE` where `TYPE` is `commit` or `pr` (default: `commit`)
67
+ - `--base BRANCH` base branch for PR diff (default: `main`)
68
+ - `--no-copy` print output only, skip clipboard copy
69
+ - `--candidates N` generate `N` output candidates (`1`-`5`, default: `1`)
70
+ - `-h`, `--help` show help
71
+
72
+ ## Commit Flow (`--type commit`)
73
+
74
+ 1. Shows `git status --short`.
75
+ 2. Asks for confirmation before staging (`git add -A`).
76
+ 3. Ensures there are staged changes.
77
+ 4. Reads staged diff and generates commit message candidate(s).
78
+ - If the AI draft misses a conventional commit prefix, Commiti auto-normalizes it to a valid conventional subject.
79
+ 5. If `--candidates` is greater than `1`, shows numbered candidates and asks you to select one.
80
+ 6. Shows selected message and asks: `Commit with this message? [y/e/N]`
81
+ - `y`: commit now
82
+ - `e`: open editor, then validate and re-confirm
83
+ - `N`: skip commit
84
+ 7. Writes commit with `git commit --file <tempfile>`.
85
+
86
+ ### Commit Message Validation
87
+
88
+ - First line must use a conventional commit prefix (e.g. `feat:`, `fix:`).
89
+ - First line must be 100 characters or fewer.
90
+
91
+ ### Why `--file` instead of `-m`
92
+
93
+ Multi-line messages and special characters are safer with `git commit --file`, avoiding shell quoting edge cases.
94
+
95
+ ### Editor Selection
96
+
97
+ Commit edit mode uses:
98
+
99
+ 1. `VISUAL`
100
+ 2. `EDITOR`
101
+ 3. Fallback: `notepad` on Windows, `vi` on non-Windows
102
+
103
+ ## PR Flow (`--type pr`)
104
+
105
+ 1. Reads branch diff: `git diff <base>...HEAD`.
106
+ 2. Generates PR description with these sections:
107
+ - `## Summary`
108
+ - `## Motivation`
109
+ - `## Changes Made`
110
+ - `## Testing Notes`
111
+ 3. Builds provider compare/MR URL with prefilled title/body using query params.
112
+ - GitHub/GitBucket: compare URL
113
+ - GitLab: new merge request URL
114
+ - If the URL would exceed safe browser/provider limits, Commiti drops description prefill automatically and keeps the shortest usable URL.
115
+ 4. Asks before opening browser.
116
+
117
+ The tool opens a browser URL only. It does not call provider APIs.
118
+
119
+ ### Diff Context Protocol
120
+
121
+ When a diff exceeds internal size limits, Commiti clips and summarizes using file-aware rules:
122
+
123
+ - Keeps full `diff --git` file headers where possible.
124
+ - Preserves `@@ ... @@` hunk headers before clipping hunk bodies.
125
+ - Includes as many complete files/hunks as fit in the limit, then appends a clipping notice.
126
+ - Summarizes large chunks asynchronously and in batches to reduce total LLM round trips.
127
+ - Falls back to deterministic file-level summaries if model summarization times out.
128
+
129
+ This improves semantic quality for AI generation compared with naive truncation.
130
+
131
+ ## Examples
132
+
133
+ Generate commit message and commit interactively:
134
+
135
+ ```bash
136
+ bundle exec ruby -Ilib bin/commiti --type commit
137
+ ```
138
+
139
+ Generate multiple commit message candidates and pick one:
140
+
141
+ ```bash
142
+ bundle exec ruby -Ilib bin/commiti --type commit --candidates 3
143
+ ```
144
+
145
+ Generate PR description against `develop`:
146
+
147
+ ```bash
148
+ bundle exec ruby -Ilib bin/commiti --type pr --base develop
149
+ ```
150
+
151
+ Print only, do not copy to clipboard:
152
+
153
+ ```bash
154
+ bundle exec ruby -Ilib bin/commiti --type pr --no-copy
155
+ ```
156
+
157
+ ## Implementation Overview
158
+
159
+ Main entrypoint and flow orchestration:
160
+
161
+ - `bin/commiti`: CLI parsing and flow dispatch
162
+ - `lib/flows/base_flow.rb`: shared generation pipeline and quality checks
163
+ - `lib/flows/commit_flow.rb`: commit-specific staging/edit/commit interactions
164
+ - `lib/flows/pr_flow.rb`: PR-specific URL generation/open flow
165
+
166
+ Core services:
167
+
168
+ - `lib/services/git_reader.rb`
169
+ - `lib/services/git/git_reader.rb`: Reads staged diff and branch diff, applies file-aware clipping, provides recent commits helper.
170
+ - `lib/services/git/git_writer.rb`: Reads status/staged state, stages (`git add -A`), commits with message file (`git commit --file`), reads branch and origin remote.
171
+ - `lib/services/git/diff_parser.rb`: Parses diff blocks and derives change metadata.
172
+ - `lib/services/git/pr/pr_opener.rb`: Parses GitHub/GitLab/GitBucket remotes, builds provider-specific PR/MR URL, opens browser cross-platform.
173
+ - `lib/services/google_client.rb`: Sends generation requests to Google Generative Language API.
174
+ - `lib/services/diff_summarization/diff_summarizer.rb`: Orchestrates large-diff summarization and summary combine.
175
+ - `lib/services/diff_summarization/batch_runner.rb`: Runs asynchronous, batched per-file summarization jobs.
176
+ - `lib/services/diff_summarization/fallback_builder.rb`: Builds deterministic summaries when model summarization fails or times out.
177
+ - `lib/services/helpers/config_loader.rb`: Loads configuration from environment variables.
178
+ - `lib/services/helpers/prompt_builder.rb`: Builds strict system/user prompts for commit and PR modes.
179
+ - `lib/services/helpers/interactive_prompt.rb`: Handles confirmation prompts, candidate selection, editor loop, and commit message validation.
180
+ - `lib/services/helpers/clipboard.rb`: Provides cross-platform clipboard support.
181
+ - `lib/services/helpers/spinner.rb`: Displays a spinner for long-running operations.
182
+ - `lib/services/message_generator.rb`: Generates commit and PR messages with quality checks.
183
+ - `lib/services/message_presenter.rb`: Presents generated messages to the user.
184
+ - `lib/services/flow_context_builder.rb`: Builds the context for different Commiti flows.
185
+ - `lib/services/git/commit/commit_staging.rb`: Handles staging changes for a commit.
186
+ - `lib/services/git/commit/commit_execution.rb`: Executes the git commit command.
187
+
188
+ Service loading:
189
+
190
+ - `lib/commiti.rb` requires all service modules.
191
+
192
+ ## Error Handling
193
+
194
+ The CLI reports user-friendly errors for common cases such as:
195
+
196
+ - No changes/staged changes
197
+ - Invalid or missing Git data
198
+ - Google AI API connectivity/authentication failures
199
+ - Summarization timeouts on large diffs (automatically falls back to a deterministic summary and continues)
200
+ - Browser open failures
201
+
202
+ ## Notes
203
+
204
+ - The default model is `gemma-4-31b-it` in `GoogleClient`.
205
+ - PR browser URL body payloads are URL-encoded with `URI.encode_www_form`.
206
+ - You can tune summarization worker concurrency with `DIFF_SUMMARY_WORKERS`.
data/bin/commiti ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'dotenv/load'
6
+ require 'commiti'
7
+
8
+ options = {
9
+ type: :commit
10
+ }
11
+
12
+ OptionParser.new do |opts|
13
+ opts.banner = <<~BANNER
14
+
15
+ AI Commit - Powered by Google AI (#{Commiti::GoogleClient::DEFAULT_MODEL})
16
+ Usage: commiti [options]
17
+
18
+ BANNER
19
+
20
+ opts.on('--type TYPE', %i[commit pr], 'Message type: commit or pr (default: commit)') do |t|
21
+ options[:type] = t
22
+ end
23
+
24
+ opts.on('--base BRANCH', 'Base branch for PR diff (default: main)') do |b|
25
+ options[:base_branch] = b
26
+ end
27
+
28
+ opts.on('--no-copy', 'Print output only, skip clipboard copy') do
29
+ options[:no_copy] = true
30
+ end
31
+
32
+ opts.on('--candidates N', Integer, 'Number of output candidates to generate (1-5, default: 1)') do |n|
33
+ raise OptionParser::InvalidArgument, 'candidates must be between 1 and 5' unless n.between?(1, 5)
34
+
35
+ options[:candidates] = n
36
+ end
37
+
38
+ opts.on('-h', '--help', 'Show this help') do
39
+ puts opts
40
+ exit
41
+ end
42
+ end.parse!
43
+
44
+ begin
45
+ flow = if options[:type] == :pr
46
+ Commiti::Flows::PrFlow.new(options: options)
47
+ else
48
+ Commiti::Flows::CommitFlow.new(options: options)
49
+ end
50
+
51
+ flow.run
52
+ rescue RuntimeError => e
53
+ puts "\nError: #{e.message}\n\n"
54
+ exit 1
55
+ rescue SocketError, Errno::ECONNREFUSED
56
+ puts "\nError: Could not connect to Google AI API. Check network access and DNS resolution.\n\n"
57
+ exit 1
58
+ rescue Net::OpenTimeout, Net::ReadTimeout
59
+ puts "\nError: Google AI request timed out while generating output. Try again or use a smaller diff.\n\n"
60
+ exit 1
61
+ rescue StandardError => e
62
+ puts "\nError: Something went wrong (#{e.class}).\n\n"
63
+ exit 1
64
+ end
data/lib/commiti.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'services/git/git_reader'
4
+ require_relative 'services/git/git_writer'
5
+ require_relative 'services/google_client'
6
+ require_relative 'services/helpers/config_loader'
7
+ require_relative 'services/git/diff_parser'
8
+ require_relative 'services/diff_summarization/diff_summarizer'
9
+ require_relative 'services/helpers/prompt_builder'
10
+ require_relative 'services/helpers/interactive_prompt'
11
+ require_relative 'services/git/pr/pr_opener'
12
+ require_relative 'services/helpers/clipboard'
13
+ require_relative 'services/helpers/spinner'
14
+ require_relative 'services/flow_context_builder'
15
+ require_relative 'services/message_generator'
16
+ require_relative 'services/message_presenter'
17
+ require_relative 'services/git/commit/commit_staging'
18
+ require_relative 'services/git/commit/commit_execution'
19
+ require_relative 'flows/base_flow'
20
+ require_relative 'flows/commit_flow'
21
+ require_relative 'flows/pr_flow'
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module Flows
5
+ class BaseFlow
6
+ def initialize(options:)
7
+ # Merge defaults/config file with CLI options (CLI options win)
8
+ @options = Commiti::ConfigLoader.load.merge(options || {})
9
+ end
10
+
11
+ def run
12
+ prepare!
13
+ diff = collect_diff
14
+ client = Commiti::GoogleClient.new(config: options)
15
+ selected_model = options[:model]
16
+ context = Commiti::FlowContextBuilder.build(
17
+ flow_type: flow_type,
18
+ diff: diff,
19
+ client: client,
20
+ run_stage: method(:run_stage),
21
+ model: selected_model
22
+ )
23
+ Commiti::MessagePresenter.print_summarization_notice(context[:summarized_result])
24
+
25
+ candidates = generate_candidates(
26
+ client: client,
27
+ prompt: context[:prompt],
28
+ diff_metadata: context[:diff_metadata],
29
+ model: selected_model
30
+ )
31
+ message = select_message(candidates)
32
+
33
+ maybe_copy_to_clipboard(message)
34
+ finalize(message)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :options
40
+
41
+ def prepare!; end
42
+
43
+ def collect_diff
44
+ raise NotImplementedError, "#{self.class} must implement #collect_diff"
45
+ end
46
+
47
+ def flow_type
48
+ raise NotImplementedError, "#{self.class} must implement #flow_type"
49
+ end
50
+
51
+ def finalize(_message); end
52
+
53
+ def run_stage(message, &)
54
+ Commiti::Spinner.run(message, &)
55
+ end
56
+
57
+ def generate_with_quality_check(client:, prompt:, diff_metadata:, model:)
58
+ message_generator.generate_with_quality_check(
59
+ client: client,
60
+ prompt: prompt,
61
+ diff_metadata: diff_metadata,
62
+ model: model
63
+ )
64
+ end
65
+
66
+ def generate_candidates(client:, prompt:, diff_metadata:, model:)
67
+ count = options[:candidates].to_i
68
+ message_generator.generate_candidates(
69
+ client: client,
70
+ prompt: prompt,
71
+ diff_metadata: diff_metadata,
72
+ count: count,
73
+ model: model
74
+ )
75
+ end
76
+
77
+ def select_message(candidates)
78
+ Commiti::MessagePresenter.select_message(candidates)
79
+ end
80
+
81
+ def print_message(message)
82
+ Commiti::MessagePresenter.print_message(message)
83
+ end
84
+
85
+ def maybe_copy_to_clipboard(message)
86
+ Commiti::MessagePresenter.maybe_copy_to_clipboard(
87
+ message,
88
+ no_copy: options[:no_copy],
89
+ run_stage: method(:run_stage)
90
+ )
91
+ end
92
+
93
+ def message_generator
94
+ @message_generator ||= Commiti::MessageGenerator.new(flow_type: flow_type, run_stage: method(:run_stage))
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module Flows
5
+ class CommitFlow < BaseFlow
6
+ private
7
+
8
+ def flow_type
9
+ :commit
10
+ end
11
+
12
+ def prepare!
13
+ Commiti::CommitStaging.prepare(run_stage: method(:run_stage))
14
+ end
15
+
16
+ def collect_diff
17
+ run_stage('Collecting staged diff') { Commiti::GitReader.staged_diff }
18
+ end
19
+
20
+ def finalize(message)
21
+ Commiti::CommitExecution.maybe_commit(
22
+ message,
23
+ run_stage: method(:run_stage),
24
+ print_message: method(:print_message)
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module Flows
5
+ class PrFlow < BaseFlow
6
+ private
7
+
8
+ def flow_type
9
+ :pr
10
+ end
11
+
12
+ def collect_diff
13
+ run_stage("Collecting diff against #{options[:base_branch]}...HEAD") do
14
+ Commiti::GitReader.branch_diff(base_branch: options[:base_branch])
15
+ end
16
+ end
17
+
18
+ def finalize(message)
19
+ maybe_open_pr_page(message, options[:base_branch])
20
+ end
21
+
22
+ def maybe_open_pr_page(description, base_branch)
23
+ pr_url = run_stage('Preparing prefilled PR URL') do
24
+ head_branch = Commiti::GitWriter.current_branch
25
+ origin_url = Commiti::GitWriter.origin_url
26
+ title = Commiti::PrOpener.suggest_title(description, head_branch: head_branch)
27
+ Commiti::PrOpener.compare_url(
28
+ origin_url: origin_url,
29
+ base_branch: base_branch,
30
+ head_branch: head_branch,
31
+ title: title,
32
+ body: description
33
+ )
34
+ end
35
+
36
+ if Commiti::InteractivePrompt.ask_yes_no('Open prefilled PR page in browser now?', default: :no)
37
+ run_stage('Opening browser') { Commiti::PrOpener.open_in_browser(pr_url) }
38
+ puts "\nOpened PR page:\n#{pr_url}\n\n"
39
+ else
40
+ puts "\nPR URL:\n#{pr_url}\n\n"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module DiffSummarizer
5
+ module BatchRunner
6
+ def summarize_chunks(chunks, client:, model:)
7
+ results = Array.new(chunks.length)
8
+ large_jobs = []
9
+
10
+ chunks.each_with_index do |chunk, index|
11
+ if chunk[:diff].bytesize > CHUNK_THRESHOLD
12
+ large_jobs << { index: index, chunk: chunk }
13
+ else
14
+ results[index] = format_chunk_summary(path: chunk[:path], summary: mechanical_summary(chunk[:diff]))
15
+ end
16
+ end
17
+
18
+ batched_jobs = build_batch_jobs(large_jobs)
19
+ run_async_summary_jobs(batched_jobs, results: results, client: client, model: model) unless batched_jobs.empty?
20
+ results
21
+ end
22
+
23
+ def run_async_summary_jobs(jobs, results:, client:, model:)
24
+ queue = Queue.new
25
+ jobs.each { |job| queue << job }
26
+
27
+ worker_count = summary_worker_count(jobs.length)
28
+ captured_errors = Queue.new
29
+
30
+ workers = Array.new(worker_count) do
31
+ Thread.new do
32
+ loop do
33
+ job = queue.pop(true)
34
+ process_batch_job(job, results: results, client: client, model: model)
35
+ rescue ThreadError
36
+ break
37
+ rescue StandardError => e
38
+ captured_errors << e
39
+ break
40
+ end
41
+ end
42
+ end
43
+
44
+ workers.each(&:join)
45
+ raise captured_errors.pop unless captured_errors.empty?
46
+ end
47
+
48
+ def process_batch_job(job, results:, client:, model:)
49
+ items = job[:items]
50
+ if items.length == 1
51
+ item = items.first
52
+ summary = summarize_single_chunk(item[:chunk], client: client, model: model)
53
+ results[item[:index]] = format_chunk_summary(path: item[:chunk][:path], summary: summary)
54
+ return
55
+ end
56
+
57
+ summaries = summarize_chunk_batch(items, client: client, model: model)
58
+ items.each do |item|
59
+ summary = summaries[item[:chunk][:path].to_s]
60
+ summary ||= summarize_single_chunk(item[:chunk], client: client, model: model)
61
+ results[item[:index]] = format_chunk_summary(path: item[:chunk][:path], summary: summary)
62
+ end
63
+ end
64
+
65
+ def build_batch_jobs(jobs)
66
+ batched = []
67
+ current = []
68
+ current_bytes = 0
69
+
70
+ jobs.each do |job|
71
+ chunk_bytes = job[:chunk][:diff].bytesize
72
+ should_split = !current.empty? && (
73
+ current.length >= MAX_BATCH_FILES ||
74
+ current_bytes + chunk_bytes > MAX_BATCH_BYTES
75
+ )
76
+
77
+ if should_split
78
+ batched << { items: current }
79
+ current = []
80
+ current_bytes = 0
81
+ end
82
+
83
+ current << job
84
+ current_bytes += chunk_bytes
85
+ end
86
+
87
+ batched << { items: current } unless current.empty?
88
+ batched
89
+ end
90
+
91
+ def summarize_single_chunk(chunk, client:, model:)
92
+ client.generate(
93
+ system: CHUNK_SYSTEM,
94
+ user: "Summarize these changes:\n\n``diff\n#{chunk[:diff]}\n``",
95
+ model: model,
96
+ timeout_seconds: 120,
97
+ open_timeout_seconds: 10
98
+ )
99
+ end
100
+
101
+ def summarize_chunk_batch(items, client:, model:)
102
+ user = +"Summarize the following file diffs:\n\n"
103
+ items.each do |item|
104
+ path = item[:chunk][:path]
105
+ diff = item[:chunk][:diff]
106
+ user << "### #{path}\n```diff\n#{diff}\n```\n\n"
107
+ end
108
+
109
+ output = client.generate(
110
+ system: BATCH_SYSTEM,
111
+ user: user.rstrip,
112
+ model: model,
113
+ timeout_seconds: 120,
114
+ open_timeout_seconds: 10
115
+ )
116
+
117
+ parse_batched_summary_output(output, expected_paths: items.map { |item| item[:chunk][:path].to_s })
118
+ end
119
+
120
+ def parse_batched_summary_output(output, expected_paths:)
121
+ sections = output.to_s.split(/^### /).map(&:strip).reject(&:empty?)
122
+ parsed = {}
123
+
124
+ sections.each do |section|
125
+ lines = section.lines
126
+ path = lines.first.to_s.strip
127
+ next unless expected_paths.include?(path)
128
+
129
+ summary = lines[1..].to_a.join.strip
130
+ parsed[path] = summary unless summary.empty?
131
+ end
132
+
133
+ parsed
134
+ end
135
+
136
+ def summary_worker_count(job_count)
137
+ configured = Integer(ENV.fetch('DIFF_SUMMARY_WORKERS', DEFAULT_SUMMARY_WORKERS))
138
+ configured.clamp(1, job_count)
139
+ rescue ArgumentError
140
+ DEFAULT_SUMMARY_WORKERS.clamp(1, job_count)
141
+ end
142
+
143
+ def format_chunk_summary(path:, summary:)
144
+ "### #{path}\n#{summary.to_s.strip}"
145
+ end
146
+ end
147
+ end
148
+ end