commiti 1.3.1 → 1.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 026566db469248936e47f40faa446650926b7bb893089246a6c23a45dfaa432a
4
- data.tar.gz: 251995106b1883d61cb12c00f911a4e3158d6d47e9b9d1b434d2026234dd3c2f
3
+ metadata.gz: 82b26f3eb5a9606aa58e723e85a764395b485d7d84175878560173262c266615
4
+ data.tar.gz: 4e92dadbaf1f3fc84f15b4650b632a054aa0ddf924349590d652361964213db5
5
5
  SHA512:
6
- metadata.gz: c0d857c40f016686ee37b6b7f2d1109c5d7f79a7037c0ed41d0e49e1d3556ff57ce0db9f1da3f0f26e4956840e5a7ab0376a39799f359673068542e89a11480c
7
- data.tar.gz: 677c08e9d0d5ae62723f7bb3fdea97c3c3799dd5a3775b133ca5520af32a102d924863c5f733794e005b3ff82c79a22d30a7aaee578985118ec0af31cd13c8ce
6
+ metadata.gz: b0fe4d3e034e6c05809c8ec000b1b44eecc8de21c3d166e5f8735f96f8d718d3bcfc2472a421f540e1509c203ed91c430d99122ae128bd4cfe9824d83de93efa
7
+ data.tar.gz: d79d70ab801e09ab81c8d6d2e9339b01d799ff874225ec49684b21e9e55baf0b85352ebf8914e2710a3ed4a2d46496f567ca9ee7cce2106047da8129e90c5ec0
data/README.md CHANGED
@@ -39,25 +39,49 @@ commiti [options]
39
39
 
40
40
  ## Configuration
41
41
 
42
- Commiti uses a single configuration approach: environment variables.
42
+ Commiti uses environment variables for secrets and a checked-in project config file for text-generation styling.
43
43
 
44
44
  Set variables in your shell, CI secret manager, or local `.env` file (in your project):
45
45
 
46
46
  ```dotenv
47
47
  GOOGLE_API_KEY=your_google_ai_key
48
48
 
49
+ # Optional: provider API tokens for API-first PR/MR creation
50
+ # COMMITI_GITHUB_TOKEN=your_github_token
51
+ # COMMITI_GITLAB_TOKEN=your_gitlab_token
52
+ # COMMITI_GITBUCKET_TOKEN=your_gitbucket_token
53
+
49
54
  # Optional overrides:
50
55
  # COMMITI_MODEL=gemma-4-31b-it
51
56
  # COMMITI_CANDIDATES=1
52
57
  # COMMITI_BASE_BRANCH=main
53
58
  # COMMITI_NO_COPY=false
54
59
  # COMMITI_AUTO_SPLIT=false
60
+
61
+ # Optional per-project prompt styling (safe YAML, no code execution):
62
+ # COMMITI_CONFIG=.commiti.yml
55
63
  ```
56
64
 
57
65
  `GEMINI_API_KEY` is also accepted as an alias for `GOOGLE_API_KEY`.
58
66
 
59
67
  You can copy `.env.example` as a starting point.
60
68
 
69
+ For project-specific wording and structure, add a `.commiti.yml` file at the repo root:
70
+
71
+ ```yaml
72
+ text_generation:
73
+ commit:
74
+ subject_case: uppercase # uppercase, lowercase, or preserve
75
+ pr:
76
+ sections:
77
+ - name: Overview
78
+ guidance: Summarize the change in one paragraph.
79
+ - name: Validation
80
+ guidance: Describe the checks or tests that were run.
81
+ ```
82
+
83
+ The file is parsed with safe YAML loading, and Commiti only accepts declarative styling settings from it.
84
+
61
85
  Your API key is sent directly from your local process to Google's API.
62
86
  Commiti does not store it and does not proxy requests through any Commiti server.
63
87
  Never commit `.env` to git.
@@ -113,14 +137,52 @@ Commit edit mode uses:
113
137
  - `## Motivation`
114
138
  - `## Changes Made`
115
139
  - `## Testing Notes`
116
- 3. Builds provider compare/MR URL with prefilled title/body using query params.
117
- - GitHub: compare URL with `quick_pull=1` (opens the PR form directly)
118
- - GitBucket: compare URL with `expand=1`
119
- - GitLab: new merge request URL
120
- - If the URL would exceed safe browser/provider limits, Commiti drops description prefill automatically and keeps the shortest usable URL.
140
+ 3. Attempts to create and open PR/MR:
141
+ - **API-first path** (when token is configured):
142
+ - GitHub/GitBucket: creates PR via provider API and opens the created PR URL.
143
+ - GitLab: creates MR via provider API and opens the created MR URL.
144
+ - **Fallback path** (when no token, provider unsupported, or API call fails):
145
+ - Opens browser with prefilled PR/MR form using query parameters.
146
+ - If the URL would exceed safe browser/provider limits (~1800 characters), Commiti keeps the title and intelligently truncates the description to the longest text that still fits.
121
147
  4. Asks before opening browser.
122
148
 
123
- The tool opens a browser URL only. It does not call provider APIs.
149
+ Commiti can create PRs/MRs via provider APIs when tokens are configured, and always opens the resulting page in your browser.
150
+
151
+ ### Provider API Logic
152
+
153
+ When you set a provider token in your configuration, Commiti uses an **API-first strategy**:
154
+
155
+ **Supported Providers:**
156
+ - **GitHub** (github.com and GitHub Enterprise): Uses GitHub REST API v3
157
+ - **GitLab** (gitlab.com and self-hosted): Uses GitLab API v4
158
+ - **GitBucket**: Uses GitHub-compatible API
159
+
160
+ **Token Configuration:**
161
+ ```dotenv
162
+ COMMITI_GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
163
+ COMMITI_GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
164
+ COMMITI_GITBUCKET_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
165
+ ```
166
+
167
+ **API Request Flow:**
168
+ 1. Parses the Git remote URL to extract provider, host, namespace, and repository.
169
+ 2. Constructs provider-specific API endpoint and authentication headers.
170
+ 3. Sends HTTP POST request with generated PR title and description.
171
+ 4. On success (HTTP 2xx): Returns the created PR/MR URL directly.
172
+ 5. On failure: Falls back to browser prefill with a user-friendly error message explaining why.
173
+
174
+ **Error Handling:**
175
+ - **Missing token**: Falls back to browser prefill. (Info message)
176
+ - **Unsupported provider**: Falls back to browser prefill. (Warning message)
177
+ - **API error**: Falls back to browser prefill with error details. (Warning message)
178
+ - **Redirect handling**: Automatically follows HTTP redirects (301, 302, 307, 308) but aborts if redirected to a different host.
179
+ - **Network errors**: Caught and reported with fallback to browser prefill.
180
+
181
+ **Advantages of API-First:**
182
+ - Creates PR/MR immediately without manual form interaction.
183
+ - Preserves full description text (no URL length constraints).
184
+ - Seamlessly opens the created PR/MR for immediate review and collaboration.
185
+ - Gracefully degrades to browser prefill if API is unavailable.
124
186
 
125
187
  ### Diff Context Protocol
126
188
 
@@ -180,7 +242,7 @@ Core services:
180
242
  - `lib/services/diff_summarization/diff_summarizer.rb`: Orchestrates large-diff summarization and summary combine.
181
243
  - `lib/services/diff_summarization/batch_runner.rb`: Runs asynchronous, batched per-file summarization jobs.
182
244
  - `lib/services/diff_summarization/fallback_builder.rb`: Builds deterministic summaries when model summarization fails or times out.
183
- - `lib/services/helpers/config_loader.rb`: Loads configuration from environment variables.
245
+ - `lib/services/helpers/config_loader.rb`: Loads environment config plus secure project-level text-generation styling.
184
246
  - `lib/services/helpers/prompt_builder.rb`: Builds strict system/user prompts for commit and PR modes.
185
247
  - `lib/services/helpers/interactive_prompt.rb`: Handles confirmation prompts, candidate selection, editor loop, and commit message validation.
186
248
  - `lib/services/helpers/clipboard.rb`: Provides cross-platform clipboard support.
data/lib/commiti.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'services/git/git_reader'
4
4
  require_relative 'services/git/git_writer'
5
+ require_relative 'services/text_generation_style'
5
6
  require_relative 'services/google_client'
6
7
  require_relative 'services/helpers/config_loader'
7
8
  require_relative 'services/git/diff_parser'
@@ -9,6 +10,7 @@ require_relative 'services/diff_summarization/diff_summarizer'
9
10
  require_relative 'services/helpers/prompt_builder'
10
11
  require_relative 'services/helpers/interactive_prompt'
11
12
  require_relative 'services/git/pr/pr_opener'
13
+ require_relative 'services/git/pr/pr_creator'
12
14
  require_relative 'services/helpers/clipboard'
13
15
  require_relative 'services/helpers/terminal_ui'
14
16
  require_relative 'services/helpers/spinner'
@@ -18,7 +18,8 @@ module Commiti
18
18
  diff: diff,
19
19
  client: client,
20
20
  run_stage: method(:run_stage),
21
- model: selected_model
21
+ model: selected_model,
22
+ text_generation_config: options[:text_generation]
22
23
  )
23
24
  Commiti::MessagePresenter.print_summarization_notice(context[:summarized_result])
24
25
 
@@ -91,7 +92,11 @@ module Commiti
91
92
  end
92
93
 
93
94
  def message_generator
94
- @message_generator ||= Commiti::MessageGenerator.new(flow_type: flow_type, run_stage: method(:run_stage))
95
+ @message_generator ||= Commiti::MessageGenerator.new(
96
+ flow_type: flow_type,
97
+ run_stage: method(:run_stage),
98
+ text_generation_config: options[:text_generation]
99
+ )
95
100
  end
96
101
  end
97
102
  end
data/lib/flows/pr_flow.rb CHANGED
@@ -20,25 +20,53 @@ module Commiti
20
20
  end
21
21
 
22
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(
23
+ head_branch = Commiti::GitWriter.current_branch
24
+ origin_url = Commiti::GitWriter.origin_url
25
+ title = Commiti::PrOpener.suggest_title(description, head_branch: head_branch)
26
+
27
+ prompt_text = 'Create PR and open it in browser now?'
28
+
29
+ unless Commiti::InteractivePrompt.ask_yes_no(prompt_text, default: :no)
30
+ puts "\nPR creation skipped.\n\n"
31
+ return
32
+ end
33
+
34
+ api_result = run_stage('Creating PR/MR via provider API (if token configured)') do
35
+ Commiti::PrCreator.create(
28
36
  origin_url: origin_url,
29
37
  base_branch: base_branch,
30
38
  head_branch: head_branch,
31
39
  title: title,
32
- body: description
40
+ body: description,
41
+ config: options
33
42
  )
34
43
  end
35
44
 
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"
45
+ pr_url = api_result[:url]
46
+
47
+ if pr_url.nil?
48
+ case api_result[:reason]
49
+ when :missing_token
50
+ puts "\n#{Commiti::TerminalUI.status(:info, "No #{api_result[:provider]} token configured; using browser prefill fallback.")}"
51
+ when :unsupported_provider
52
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'Provider API is unsupported; using browser prefill fallback.')}"
53
+ when :api_error
54
+ puts "\n#{Commiti::TerminalUI.status(:warn, "PR API create failed: #{api_result[:error]}. Using browser prefill fallback.")}"
55
+ end
56
+
57
+ pr_url = run_stage('Preparing prefilled PR URL') do
58
+ Commiti::PrOpener.compare_url(
59
+ origin_url: origin_url,
60
+ base_branch: base_branch,
61
+ head_branch: head_branch,
62
+ title: title,
63
+ body: description
64
+ )
65
+ end
41
66
  end
67
+
68
+ run_stage('Opening browser') { Commiti::PrOpener.open_in_browser(pr_url) }
69
+ puts "\nOpened PR page:\n#{pr_url}\n\n"
42
70
  end
43
71
  end
44
72
  end
@@ -12,49 +12,88 @@ module Commiti
12
12
 
13
13
  def fallback_summary(diff, chunks: nil)
14
14
  parsed_chunks = chunks || Commiti::DiffParser.split_by_file(diff)
15
- files = []
15
+ files = file_stats_for(parsed_chunks)
16
+ return diff.to_s[0, FALLBACK_BYTES] if files.empty?
16
17
 
17
- parsed_chunks.each do |chunk|
18
- current = {
19
- path: chunk[:path].to_s,
20
- additions: 0,
21
- deletions: 0,
22
- status: 'modified'
23
- }
18
+ render_fallback_summary(files)
19
+ end
24
20
 
25
- chunk[:diff].to_s.each_line do |line|
26
- stripped = line.strip
27
- current[:status] = 'added' if stripped.start_with?('new file mode')
28
- current[:status] = 'deleted' if stripped.start_with?('deleted file mode')
29
- current[:status] = 'renamed' if stripped.start_with?('rename from ') || stripped.start_with?('rename to ')
21
+ private
30
22
 
31
- next if line.start_with?('diff --git ', '+++', '---', '@@')
23
+ def file_stats_for(chunks)
24
+ chunks.map { |chunk| file_stats_for_chunk(chunk) }
25
+ end
26
+
27
+ def file_stats_for_chunk(chunk)
28
+ status, additions, deletions = file_status_and_counts(chunk[:diff])
29
+ {
30
+ path: chunk[:path].to_s,
31
+ additions: additions,
32
+ deletions: deletions,
33
+ status: status
34
+ }
35
+ end
32
36
 
33
- current[:additions] += 1 if line.start_with?('+')
34
- current[:deletions] += 1 if line.start_with?('-')
35
- end
37
+ def file_status_and_counts(diff_text)
38
+ status = 'modified'
39
+ additions = 0
40
+ deletions = 0
36
41
 
37
- files << current
42
+ diff_text.to_s.each_line do |line|
43
+ status = detect_status(line, current: status)
44
+ next if metadata_line?(line)
45
+
46
+ additions += 1 if line.start_with?('+')
47
+ deletions += 1 if line.start_with?('-')
38
48
  end
39
49
 
40
- return diff.to_s[0, FALLBACK_BYTES] if files.empty?
50
+ [status, additions, deletions]
51
+ end
52
+
53
+ def detect_status(line, current:)
54
+ stripped = line.strip
55
+ return 'added' if stripped.start_with?('new file mode')
56
+ return 'deleted' if stripped.start_with?('deleted file mode')
57
+ return 'renamed' if stripped.start_with?('rename from ') || stripped.start_with?('rename to ')
41
58
 
42
- lines = []
43
- lines << '### Diff Overview'
44
- lines << "- Total files changed: #{files.length}"
45
- lines << ''
59
+ current
60
+ end
61
+
62
+ def metadata_line?(line)
63
+ line.start_with?('diff --git ', '+++', '---', '@@')
64
+ end
65
+
66
+ def render_fallback_summary(files)
67
+ summary_lines = [
68
+ '### Diff Overview',
69
+ "- Total files changed: #{files.length}",
70
+ ''
71
+ ]
72
+
73
+ append_file_sections(summary_lines, files)
74
+ append_truncation_notice(summary_lines, files)
46
75
 
76
+ summary_lines.join("\n").strip
77
+ end
78
+
79
+ def append_file_sections(summary_lines, files)
47
80
  files.first(MAX_FILES_IN_SUMMARY).each do |file|
48
- lines << "### #{file[:path]}"
49
- lines << "- Status: #{file[:status]}"
50
- lines << "- Added lines: #{file[:additions]}"
51
- lines << "- Removed lines: #{file[:deletions]}"
52
- lines << ''
81
+ summary_lines.concat(render_file_section(file))
53
82
  end
83
+ end
54
84
 
55
- lines << "...and #{files.length - MAX_FILES_IN_SUMMARY} more files" if files.length > MAX_FILES_IN_SUMMARY
85
+ def append_truncation_notice(summary_lines, files)
86
+ summary_lines << "...and #{files.length - MAX_FILES_IN_SUMMARY} more files" if files.length > MAX_FILES_IN_SUMMARY
87
+ end
56
88
 
57
- lines.join("\n").strip
89
+ def render_file_section(file)
90
+ [
91
+ "### #{file[:path]}",
92
+ "- Status: #{file[:status]}",
93
+ "- Added lines: #{file[:additions]}",
94
+ "- Removed lines: #{file[:deletions]}",
95
+ ''
96
+ ]
58
97
  end
59
98
  end
60
99
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Commiti
4
4
  module FlowContextBuilder
5
- def self.build(flow_type:, diff:, client:, run_stage:, model:)
5
+ def self.build(flow_type:, diff:, client:, run_stage:, model:, text_generation_config: nil)
6
6
  line_chunks = Commiti::DiffParser.split_by_file_lines(diff)
7
7
  diff_metadata = Commiti::DiffParser.metadata_from_line_chunks(line_chunks)
8
8
  change_groups = Commiti::ChangeGrouping.group(line_chunks)
@@ -21,7 +21,8 @@ module Commiti
21
21
  diff: summarized_result[:content],
22
22
  summarized: summarized_result[:summarized],
23
23
  raw_diff: diff,
24
- diff_metadata: diff_metadata
24
+ diff_metadata: diff_metadata,
25
+ style_config: text_generation_config
25
26
  )
26
27
 
27
28
  {
@@ -10,31 +10,14 @@ module Commiti
10
10
 
11
11
  case action
12
12
  when :yes
13
- errors = Commiti::InteractivePrompt.commit_message_errors(working_message)
14
- unless errors.empty?
15
- puts "\n#{Commiti::TerminalUI.status(:warn, 'Current message needs fixes before commit:')}"
16
- errors.each { |error| puts "- #{error}" }
17
-
18
- if Commiti::InteractivePrompt.ask_yes_no('Open editor to fix now?', default: :yes)
19
- edited = edit_message_until_valid(working_message)
20
- if edited.nil?
21
- puts "\n#{Commiti::TerminalUI.status(:fail, 'Editor did not exit successfully. Commit skipped.')}\n\n"
22
- return :skipped
23
- end
24
-
25
- working_message = edited
26
- print_message.call(working_message)
27
- next
28
- end
29
-
30
- puts "\n#{Commiti::TerminalUI.status(:warn, 'Commit skipped.')}\n\n"
31
- return :skipped
32
- end
13
+ result, next_message = handle_yes_action(
14
+ working_message,
15
+ run_stage: run_stage,
16
+ print_message: print_message
17
+ )
18
+ return result if result
33
19
 
34
- output = run_stage.call('Writing commit') { Commiti::GitWriter.commit_with_message_file(working_message) }
35
- puts output unless output.to_s.strip.empty?
36
- puts "\n#{Commiti::TerminalUI.status(:success, 'Commit created.')}\n\n"
37
- return :committed
20
+ working_message = next_message
38
21
  when :edit
39
22
  edited = edit_message_until_valid(working_message)
40
23
  if edited.nil?
@@ -45,12 +28,48 @@ module Commiti
45
28
  working_message = edited
46
29
  print_message.call(working_message)
47
30
  else
48
- puts "\n#{Commiti::TerminalUI.status(:warn, 'Commit skipped.')}\n\n"
31
+ print_skip_message
49
32
  return :skipped
50
33
  end
51
34
  end
52
35
  end
53
36
 
37
+ def self.handle_yes_action(working_message, run_stage:, print_message:)
38
+ errors = Commiti::InteractivePrompt.commit_message_errors(working_message)
39
+ return commit_message(working_message, run_stage: run_stage) if errors.empty?
40
+
41
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'Current message needs fixes before commit:')}"
42
+ errors.each { |error| puts "- #{error}" }
43
+
44
+ unless Commiti::InteractivePrompt.ask_yes_no('Open editor to fix now?', default: :yes)
45
+ print_skip_message
46
+ return [:skipped, nil]
47
+ end
48
+
49
+ edited = edit_message_until_valid(working_message)
50
+ if edited.nil?
51
+ puts "\n#{Commiti::TerminalUI.status(:fail, 'Editor did not exit successfully. Commit skipped.')}\n\n"
52
+ return [:skipped, nil]
53
+ end
54
+
55
+ print_message.call(edited)
56
+ [nil, edited]
57
+ end
58
+ private_class_method :handle_yes_action
59
+
60
+ def self.commit_message(message, run_stage:)
61
+ output = run_stage.call('Writing commit') { Commiti::GitWriter.commit_with_message_file(message) }
62
+ puts output unless output.to_s.strip.empty?
63
+ puts "\n#{Commiti::TerminalUI.status(:success, 'Commit created.')}\n\n"
64
+ [:committed, nil]
65
+ end
66
+ private_class_method :commit_message
67
+
68
+ def self.print_skip_message
69
+ puts "\n#{Commiti::TerminalUI.status(:warn, 'Commit skipped.')}\n\n"
70
+ end
71
+ private_class_method :print_skip_message
72
+
54
73
  def self.edit_message_until_valid(initial_message)
55
74
  working = initial_message
56
75
 
@@ -124,34 +124,44 @@ module Commiti
124
124
  output = +''
125
125
  return output if max_bytes <= 0
126
126
 
127
+ header_lines, hunks = partition_chunk_lines(lines)
128
+ append_lines_with_limit(output, header_lines, max_bytes: max_bytes)
129
+ return output if hunks.empty?
130
+
131
+ append_hunks_with_limit(output, hunks, max_bytes: max_bytes)
132
+ output
133
+ end
134
+
135
+ def self.partition_chunk_lines(lines)
127
136
  header_lines = []
128
137
  hunks = []
129
138
  current_hunk = nil
130
- in_hunks = false
131
139
 
132
140
  lines.each do |line|
133
141
  if line.start_with?('@@')
134
- in_hunks = true
135
142
  current_hunk = [line]
136
143
  hunks << current_hunk
137
- next
138
- end
139
-
140
- if in_hunks
144
+ elsif current_hunk
141
145
  current_hunk << line
142
146
  else
143
147
  header_lines << line
144
148
  end
145
149
  end
146
150
 
147
- header_lines.each do |line|
151
+ [header_lines, hunks]
152
+ end
153
+ private_class_method :partition_chunk_lines
154
+
155
+ def self.append_lines_with_limit(output, lines, max_bytes:)
156
+ lines.each do |line|
148
157
  break if output.bytesize + line.bytesize > max_bytes
149
158
 
150
159
  output << line
151
160
  end
161
+ end
162
+ private_class_method :append_lines_with_limit
152
163
 
153
- return output if hunks.empty?
154
-
164
+ def self.append_hunks_with_limit(output, hunks, max_bytes:)
155
165
  hunks.each do |hunk|
156
166
  hunk_text = hunk.join
157
167
  if output.bytesize + hunk_text.bytesize <= max_bytes
@@ -159,20 +169,20 @@ module Commiti
159
169
  next
160
170
  end
161
171
 
162
- hunk_header = hunk.first
163
- break if output.bytesize + hunk_header.bytesize > max_bytes
164
-
165
- output << hunk_header
166
- hunk[1..].to_a.each do |line|
167
- break if output.bytesize + line.bytesize > max_bytes
168
-
169
- output << line
170
- end
172
+ append_partial_hunk(output, hunk, max_bytes: max_bytes)
171
173
  break
172
174
  end
175
+ end
176
+ private_class_method :append_hunks_with_limit
173
177
 
174
- output
178
+ def self.append_partial_hunk(output, hunk, max_bytes:)
179
+ hunk_header = hunk.first
180
+ return if output.bytesize + hunk_header.bytesize > max_bytes
181
+
182
+ output << hunk_header
183
+ append_lines_with_limit(output, hunk[1..].to_a, max_bytes: max_bytes)
175
184
  end
185
+ private_class_method :append_partial_hunk
176
186
 
177
187
  def self.append_notice(clipped_diff, max_bytes:)
178
188
  safe_clipped = clipped_diff.to_s
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module PrBrowserOpener
5
+ def self.open_in_browser(url)
6
+ success = if windows?
7
+ open_windows_browser(url)
8
+ elsif mac?
9
+ system('open', url)
10
+ else
11
+ system('xdg-open', url)
12
+ end
13
+
14
+ raise 'Failed to open browser for PR URL.' unless success
15
+
16
+ nil
17
+ end
18
+
19
+ def self.open_windows_browser(url)
20
+ cleaned_url = url.to_s.strip.sub(/\A\\+/, '')
21
+
22
+ # Prefer shell protocol handler. This bypasses cmd/explorer parsing of '&'.
23
+ return true if system('rundll32', 'url.dll,FileProtocolHandler', cleaned_url)
24
+
25
+ # PowerShell fallback, passing URL as an argument to avoid command parsing.
26
+ system(
27
+ 'powershell',
28
+ '-NoProfile',
29
+ '-Command',
30
+ '$u=$args[0]; Start-Process -FilePath $u',
31
+ '--',
32
+ cleaned_url
33
+ )
34
+ end
35
+
36
+ def self.windows?
37
+ RUBY_PLATFORM.include?('mingw') || RUBY_PLATFORM.include?('mswin')
38
+ end
39
+
40
+ def self.mac?
41
+ RUBY_PLATFORM.include?('darwin')
42
+ end
43
+ end
44
+ end