commiti 1.3.1 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 026566db469248936e47f40faa446650926b7bb893089246a6c23a45dfaa432a
4
- data.tar.gz: 251995106b1883d61cb12c00f911a4e3158d6d47e9b9d1b434d2026234dd3c2f
3
+ metadata.gz: 7853991e7fa65bd7c35642c50b00aead27bc7b3a4c11ffec7bb38be03a9e96ca
4
+ data.tar.gz: a42e9fbfec33a80cf95befe00c14f3e2bd23e7d334606589e21d2e1b6ab31a6f
5
5
  SHA512:
6
- metadata.gz: c0d857c40f016686ee37b6b7f2d1109c5d7f79a7037c0ed41d0e49e1d3556ff57ce0db9f1da3f0f26e4956840e5a7ab0376a39799f359673068542e89a11480c
7
- data.tar.gz: 677c08e9d0d5ae62723f7bb3fdea97c3c3799dd5a3775b133ca5520af32a102d924863c5f733794e005b3ff82c79a22d30a7aaee578985118ec0af31cd13c8ce
6
+ metadata.gz: 82ed71618c42e1fbb745bfc04974a754b43396f232d26923c214a34738860d0a7566c90c22caa8e60dee383d953dcce0937e908a3bd81c1d1519e4cb84b3790a
7
+ data.tar.gz: b45f9669306182469b502bc98a2a1754c9ad3e9320b8801ac2a72bd3f412d0d01f348e8ac75fd5a82b002f84356b9e0ebb99df9c941ac6cc4607b8f53912c2ce
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Commiti
2
2
 
3
+ [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen)](coverage/index.html) [![RSpec](https://img.shields.io/badge/rspec-passing-brightgreen)](#)
4
+
3
5
  AI-powered commit message and pull request description generator for Git repositories, using Google AI models.
4
6
 
5
7
  ## What It Does
@@ -39,25 +41,49 @@ commiti [options]
39
41
 
40
42
  ## Configuration
41
43
 
42
- Commiti uses a single configuration approach: environment variables.
44
+ Commiti uses environment variables for secrets and a checked-in project config file for text-generation styling.
43
45
 
44
46
  Set variables in your shell, CI secret manager, or local `.env` file (in your project):
45
47
 
46
48
  ```dotenv
47
49
  GOOGLE_API_KEY=your_google_ai_key
48
50
 
51
+ # Optional: provider API tokens for API-first PR/MR creation
52
+ # COMMITI_GITHUB_TOKEN=your_github_token
53
+ # COMMITI_GITLAB_TOKEN=your_gitlab_token
54
+ # COMMITI_GITBUCKET_TOKEN=your_gitbucket_token
55
+
49
56
  # Optional overrides:
50
57
  # COMMITI_MODEL=gemma-4-31b-it
51
58
  # COMMITI_CANDIDATES=1
52
59
  # COMMITI_BASE_BRANCH=main
53
60
  # COMMITI_NO_COPY=false
54
61
  # COMMITI_AUTO_SPLIT=false
62
+
63
+ # Optional per-project prompt styling (safe YAML, no code execution):
64
+ # COMMITI_CONFIG=.commiti.yml
55
65
  ```
56
66
 
57
67
  `GEMINI_API_KEY` is also accepted as an alias for `GOOGLE_API_KEY`.
58
68
 
59
69
  You can copy `.env.example` as a starting point.
60
70
 
71
+ For project-specific wording and structure, add a `.commiti.yml` file at the repo root:
72
+
73
+ ```yaml
74
+ text_generation:
75
+ commit:
76
+ subject_case: uppercase # uppercase, lowercase, or preserve
77
+ pr:
78
+ sections:
79
+ - name: Overview
80
+ guidance: Summarize the change in one paragraph.
81
+ - name: Validation
82
+ guidance: Describe the checks or tests that were run.
83
+ ```
84
+
85
+ The file is parsed with safe YAML loading, and Commiti only accepts declarative styling settings from it.
86
+
61
87
  Your API key is sent directly from your local process to Google's API.
62
88
  Commiti does not store it and does not proxy requests through any Commiti server.
63
89
  Never commit `.env` to git.
@@ -76,6 +102,8 @@ Never commit `.env` to git.
76
102
  By default, Commiti creates a single commit from staged changes.
77
103
  Use `--auto-split` to let Commiti group connected file changes into multiple atomic commits.
78
104
 
105
+ When auto-split detects multiple groups, Commiti can optionally open a quick group editor so you can move files between groups before committing.
106
+
79
107
  1. Shows `git status --short`.
80
108
  2. Asks for confirmation before staging (`git add -A`).
81
109
  3. Ensures there are staged changes.
@@ -113,14 +141,52 @@ Commit edit mode uses:
113
141
  - `## Motivation`
114
142
  - `## Changes Made`
115
143
  - `## 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.
144
+ 3. Attempts to create and open PR/MR:
145
+ - **API-first path** (when token is configured):
146
+ - GitHub/GitBucket: creates PR via provider API and opens the created PR URL.
147
+ - GitLab: creates MR via provider API and opens the created MR URL.
148
+ - **Fallback path** (when no token, provider unsupported, or API call fails):
149
+ - Opens browser with prefilled PR/MR form using query parameters.
150
+ - 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
151
  4. Asks before opening browser.
122
152
 
123
- The tool opens a browser URL only. It does not call provider APIs.
153
+ Commiti can create PRs/MRs via provider APIs when tokens are configured, and always opens the resulting page in your browser.
154
+
155
+ ### Provider API Logic
156
+
157
+ When you set a provider token in your configuration, Commiti uses an **API-first strategy**:
158
+
159
+ **Supported Providers:**
160
+ - **GitHub** (github.com and GitHub Enterprise): Uses GitHub REST API v3
161
+ - **GitLab** (gitlab.com and self-hosted): Uses GitLab API v4
162
+ - **GitBucket**: Uses GitHub-compatible API
163
+
164
+ **Token Configuration:**
165
+ ```dotenv
166
+ COMMITI_GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
167
+ COMMITI_GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
168
+ COMMITI_GITBUCKET_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
169
+ ```
170
+
171
+ **API Request Flow:**
172
+ 1. Parses the Git remote URL to extract provider, host, namespace, and repository.
173
+ 2. Constructs provider-specific API endpoint and authentication headers.
174
+ 3. Sends HTTP POST request with generated PR title and description.
175
+ 4. On success (HTTP 2xx): Returns the created PR/MR URL directly.
176
+ 5. On failure: Falls back to browser prefill with a user-friendly error message explaining why.
177
+
178
+ **Error Handling:**
179
+ - **Missing token**: Falls back to browser prefill. (Info message)
180
+ - **Unsupported provider**: Falls back to browser prefill. (Warning message)
181
+ - **API error**: Falls back to browser prefill with error details. (Warning message)
182
+ - **Redirect handling**: Automatically follows HTTP redirects (301, 302, 307, 308) but aborts if redirected to a different host.
183
+ - **Network errors**: Caught and reported with fallback to browser prefill.
184
+
185
+ **Advantages of API-First:**
186
+ - Creates PR/MR immediately without manual form interaction.
187
+ - Preserves full description text (no URL length constraints).
188
+ - Seamlessly opens the created PR/MR for immediate review and collaboration.
189
+ - Gracefully degrades to browser prefill if API is unavailable.
124
190
 
125
191
  ### Diff Context Protocol
126
192
 
@@ -180,7 +246,7 @@ Core services:
180
246
  - `lib/services/diff_summarization/diff_summarizer.rb`: Orchestrates large-diff summarization and summary combine.
181
247
  - `lib/services/diff_summarization/batch_runner.rb`: Runs asynchronous, batched per-file summarization jobs.
182
248
  - `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.
249
+ - `lib/services/helpers/config_loader.rb`: Loads environment config plus secure project-level text-generation styling.
184
250
  - `lib/services/helpers/prompt_builder.rb`: Builds strict system/user prompts for commit and PR modes.
185
251
  - `lib/services/helpers/interactive_prompt.rb`: Handles confirmation prompts, candidate selection, editor loop, and commit message validation.
186
252
  - `lib/services/helpers/clipboard.rb`: Provides cross-platform clipboard support.
data/bin/commiti CHANGED
@@ -47,6 +47,12 @@ OptionParser.new do |opts|
47
47
  end.parse!
48
48
 
49
49
  begin
50
+ model_label = ENV.fetch('COMMITI_MODEL', Commiti::GoogleClient::DEFAULT_MODEL)
51
+ flow_label = options[:type] == :pr ? 'PR flow' : 'Commit flow'
52
+ base_label = options[:type] == :pr ? "Base: #{options[:base_branch] || 'main'}" : nil
53
+ meta = [flow_label, "Model: #{model_label}", base_label].compact.join(' • ')
54
+ puts Commiti::TerminalUI.banner(title: 'Commiti', subtitle: 'AI commit & PR generator', meta: meta)
55
+
50
56
  flow = if options[:type] == :pr
51
57
  Commiti::Flows::PrFlow.new(options: options)
52
58
  else
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,6 +20,7 @@ require_relative 'services/message_presenter'
18
20
  require_relative 'services/git/commit/commit_staging'
19
21
  require_relative 'services/git/commit/commit_execution'
20
22
  require_relative 'services/git/commit/change_grouping'
23
+ require_relative 'services/git/commit/group_editor'
21
24
  require_relative 'flows/base_flow'
22
25
  require_relative 'flows/commit_flow'
23
26
  require_relative 'flows/pr_flow'
@@ -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
@@ -61,7 +61,12 @@ module Commiti
61
61
  end
62
62
 
63
63
  def run_grouped_context(context:, client:, model:)
64
- groups = context[:change_groups]
64
+ groups = Commiti::GroupEditor.edit(context[:change_groups])
65
+ if groups.length <= 1
66
+ single_context = groups.first ? build_context(diff: group_diff(groups.first), client:, model:) : context
67
+ return run_single_group_context(context: single_context, client:, model:)
68
+ end
69
+
65
70
  run_stage('Unstaging current index for grouped commit execution') { Commiti::GitWriter.unstage_all! }
66
71
 
67
72
  puts "\n#{Commiti::TerminalUI.status(:info, "Auto-split detected #{groups.length} connected change groups.")}"
@@ -75,8 +80,7 @@ module Commiti
75
80
  run_stage("Staging files for group #{index + 1}/#{total}") { Commiti::GitWriter.stage_files!(group[:files]) }
76
81
  return :continue unless run_stage('Checking staged changes') { Commiti::GitWriter.staged_changes? }
77
82
 
78
- puts "\n#{Commiti::TerminalUI.header("Group #{index + 1}/#{total} files")}:"
79
- group[:files].each { |path| puts "- #{path}" }
83
+ puts "\n#{Commiti::TerminalUI.panel("Group #{index + 1}/#{total} files", Commiti::TerminalUI.bullets(group[:files]))}\n"
80
84
 
81
85
  group_context = build_context(diff: group_diff(group), client:, model:)
82
86
  Commiti::MessagePresenter.print_summarization_notice(group_context[:summarized_result])
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 "\n#{Commiti::TerminalUI.status(:warn, 'PR 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 "\n#{Commiti::TerminalUI.panel('Opened PR page', 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
  {
@@ -18,6 +18,12 @@ module Commiti
18
18
  end
19
19
  end
20
20
 
21
+ def self.related?(left, right)
22
+ return false if left.to_s.strip.empty? || right.to_s.strip.empty?
23
+
24
+ connected?(left, right)
25
+ end
26
+
21
27
  def self.connected_components(paths)
22
28
  visited = {}
23
29
  ordered_components = []
@@ -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 Commiti::TerminalUI.bullet(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
 
@@ -69,7 +88,7 @@ module Commiti
69
88
  return edited if errors.empty?
70
89
 
71
90
  puts "\n#{Commiti::TerminalUI.status(:warn, 'Edited message needs fixes:')}"
72
- errors.each { |error| puts "- #{error}" }
91
+ errors.each { |error| puts Commiti::TerminalUI.bullet(error) }
73
92
  return edited unless Commiti::InteractivePrompt.ask_yes_no('Re-open editor now?', default: :yes)
74
93
 
75
94
  working = edited
@@ -11,7 +11,7 @@ module Commiti
11
11
  status = run_stage.call('Reading git status') { Commiti::GitWriter.status_short }
12
12
  raise 'No changes found in working tree.' if status.strip.empty?
13
13
 
14
- puts "\n#{Commiti::TerminalUI.header('Current git status')}\n\n#{status}"
14
+ puts "\n#{Commiti::TerminalUI.panel('Current git status', status)}\n"
15
15
  return unless Commiti::InteractivePrompt.ask_yes_no('Run git add -A now?', default: :no)
16
16
 
17
17
  run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all! }