commiti 1.2.3 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55dfe17b32ed45b600920943c16b99f39b0acff0c9c66e516db63e6fa7a0debf
4
- data.tar.gz: 9f70caaeef73f63211e93bd28ae2ae69236c0e777153c48a765066a0825e4e49
3
+ metadata.gz: 751893cc1501fbd4723682605f17bcc6f0617fafe28997b398affd8e33a20d1e
4
+ data.tar.gz: 6b05cf37e1617655547c5e2de646dd75d07cde47d0883b70ee8eb7114242719b
5
5
  SHA512:
6
- metadata.gz: a627866611dcc0974b30a005814b38226833412a1789a57f4f3559c010849313731d361dd0d0e192722577e0f1353d8fa55c2bf9487c4d83bbe596e7d5a04e16
7
- data.tar.gz: 0b8e9679be21a6f74ea419ffb4968f883fb564990eeab845c63f525190d459a151cebc17d2d852a3eba9f8293093107bd146e76cf37cae8087af88bd06f53e5e
6
+ metadata.gz: 777e2d6ddc6e40f0599abbe0ee191b819993926c5e0ca5bed79abb81407071356fa97807de031ba21dc3221a96bfa8b526765ee35a80aeee95cf9410c952214f
7
+ data.tar.gz: 739b300910794916477e8222b1ba2ea4f08698c337b1af10273c126a8fe8f3c619c6ed8171134cf545550e10767746b3b8d0da29f4cd203627ac7e26108edabf
data/README.md CHANGED
@@ -51,6 +51,7 @@ GOOGLE_API_KEY=your_google_ai_key
51
51
  # COMMITI_CANDIDATES=1
52
52
  # COMMITI_BASE_BRANCH=main
53
53
  # COMMITI_NO_COPY=false
54
+ # COMMITI_AUTO_SPLIT=false
54
55
  ```
55
56
 
56
57
  `GEMINI_API_KEY` is also accepted as an alias for `GOOGLE_API_KEY`.
@@ -67,10 +68,14 @@ Never commit `.env` to git.
67
68
  - `--base BRANCH` base branch for PR diff (default: `main`)
68
69
  - `--no-copy` print output only, skip clipboard copy
69
70
  - `--candidates N` generate `N` output candidates (`1`-`5`, default: `1`)
71
+ - `--auto-split` auto-group staged changes into multiple connected commits (commit flow only)
70
72
  - `-h`, `--help` show help
71
73
 
72
74
  ## Commit Flow (`--type commit`)
73
75
 
76
+ By default, Commiti creates a single commit from staged changes.
77
+ Use `--auto-split` to let Commiti group connected file changes into multiple atomic commits.
78
+
74
79
  1. Shows `git status --short`.
75
80
  2. Asks for confirmation before staging (`git add -A`).
76
81
  3. Ensures there are staged changes.
@@ -109,7 +114,8 @@ Commit edit mode uses:
109
114
  - `## Changes Made`
110
115
  - `## Testing Notes`
111
116
  3. Builds provider compare/MR URL with prefilled title/body using query params.
112
- - GitHub/GitBucket: compare URL
117
+ - GitHub: compare URL with `quick_pull=1` (opens the PR form directly)
118
+ - GitBucket: compare URL with `expand=1`
113
119
  - GitLab: new merge request URL
114
120
  - If the URL would exceed safe browser/provider limits, Commiti drops description prefill automatically and keeps the shortest usable URL.
115
121
  4. Asks before opening browser.
data/bin/commiti CHANGED
@@ -6,7 +6,8 @@ require 'dotenv/load'
6
6
  require 'commiti'
7
7
 
8
8
  options = {
9
- type: :commit
9
+ type: :commit,
10
+ auto_split: false
10
11
  }
11
12
 
12
13
  OptionParser.new do |opts|
@@ -35,6 +36,10 @@ OptionParser.new do |opts|
35
36
  options[:candidates] = n
36
37
  end
37
38
 
39
+ opts.on('--auto-split', 'Auto-group staged changes into multiple connected commits (commit flow only)') do
40
+ options[:auto_split] = true
41
+ end
42
+
38
43
  opts.on('-h', '--help', 'Show this help') do
39
44
  puts opts
40
45
  exit
data/lib/commiti.rb CHANGED
@@ -16,6 +16,7 @@ require_relative 'services/message_generator'
16
16
  require_relative 'services/message_presenter'
17
17
  require_relative 'services/git/commit/commit_staging'
18
18
  require_relative 'services/git/commit/commit_execution'
19
+ require_relative 'services/git/commit/change_grouping'
19
20
  require_relative 'flows/base_flow'
20
21
  require_relative 'flows/commit_flow'
21
22
  require_relative 'flows/pr_flow'
@@ -3,6 +3,12 @@
3
3
  module Commiti
4
4
  module Flows
5
5
  class CommitFlow < BaseFlow
6
+ def run
7
+ return super unless options[:auto_split]
8
+
9
+ run_auto_split
10
+ end
11
+
6
12
  private
7
13
 
8
14
  def flow_type
@@ -24,6 +30,89 @@ module Commiti
24
30
  print_message: method(:print_message)
25
31
  )
26
32
  end
33
+
34
+ def run_auto_split
35
+ prepare!
36
+ diff = collect_diff
37
+ client = Commiti::GoogleClient.new(config: options)
38
+ selected_model = options[:model]
39
+ context = build_context(diff:, client:, model: selected_model)
40
+
41
+ return run_single_group_context(context:, client:, model: selected_model) if single_group?(context)
42
+
43
+ run_grouped_context(context:, client:, model: selected_model)
44
+ rescue StandardError
45
+ run_stage('Restaging uncommitted changes after failure') { Commiti::GitWriter.stage_all! }
46
+ raise
47
+ end
48
+
49
+ def single_group?(context)
50
+ context[:change_groups].length <= 1
51
+ end
52
+
53
+ def run_single_group_context(context:, client:, model:)
54
+ puts "\nAuto-split found a single connected change group. Falling back to single commit flow."
55
+ Commiti::MessagePresenter.print_summarization_notice(context[:summarized_result])
56
+
57
+ message = generate_message_for_context(context:, client:, model:)
58
+ maybe_copy_to_clipboard(message)
59
+ finalize(message)
60
+ end
61
+
62
+ def run_grouped_context(context:, client:, model:)
63
+ groups = context[:change_groups]
64
+ run_stage('Unstaging current index for grouped commit execution') { Commiti::GitWriter.unstage_all! }
65
+
66
+ puts "\nAuto-split detected #{groups.length} connected change groups."
67
+
68
+ groups.each_with_index do |group, index|
69
+ break if process_group(group:, index:, total: groups.length, client:, model:) == :stop
70
+ end
71
+ end
72
+
73
+ def process_group(group:, index:, total:, client:, model:)
74
+ run_stage("Staging files for group #{index + 1}/#{total}") { Commiti::GitWriter.stage_files!(group[:files]) }
75
+ return :continue unless run_stage('Checking staged changes') { Commiti::GitWriter.staged_changes? }
76
+
77
+ puts "\nGroup #{index + 1}/#{total} files:"
78
+ group[:files].each { |path| puts "- #{path}" }
79
+
80
+ group_context = build_context(diff: group_diff(group), client:, model:)
81
+ Commiti::MessagePresenter.print_summarization_notice(group_context[:summarized_result])
82
+
83
+ message = generate_message_for_context(context: group_context, client:, model:)
84
+ maybe_copy_to_clipboard(message)
85
+ return :continue if finalize(message) == :committed
86
+
87
+ puts "Stopping auto-split flow at group #{index + 1} because commit was skipped."
88
+ run_stage('Restaging remaining uncommitted changes') { Commiti::GitWriter.stage_all! }
89
+ :stop
90
+ end
91
+
92
+ def build_context(diff:, client:, model:)
93
+ Commiti::FlowContextBuilder.build(
94
+ flow_type: flow_type,
95
+ diff: diff,
96
+ client: client,
97
+ run_stage: method(:run_stage),
98
+ model: model
99
+ )
100
+ end
101
+
102
+ def generate_message_for_context(context:, client:, model:)
103
+ candidates = generate_candidates(
104
+ client: client,
105
+ prompt: context[:prompt],
106
+ diff_metadata: context[:diff_metadata],
107
+ model: model
108
+ )
109
+
110
+ select_message(candidates)
111
+ end
112
+
113
+ def group_diff(group)
114
+ group[:chunks].map { |chunk| chunk[:lines].join }.join
115
+ end
27
116
  end
28
117
  end
29
118
  end
@@ -5,6 +5,7 @@ module Commiti
5
5
  def self.build(flow_type:, diff:, client:, run_stage:, model:)
6
6
  line_chunks = Commiti::DiffParser.split_by_file_lines(diff)
7
7
  diff_metadata = Commiti::DiffParser.metadata_from_line_chunks(line_chunks)
8
+ change_groups = Commiti::ChangeGrouping.group(line_chunks)
8
9
 
9
10
  summarized_result = run_stage.call('Preparing diff for AI model') do
10
11
  Commiti::DiffSummarizer.summarize_if_needed(
@@ -25,6 +26,7 @@ module Commiti
25
26
 
26
27
  {
27
28
  diff_metadata: diff_metadata,
29
+ change_groups: change_groups,
28
30
  summarized_result: summarized_result,
29
31
  prompt: prompt
30
32
  }
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module ChangeGrouping
5
+ NOISE_DIRECTORIES = %w[lib spec test app src docs].freeze
6
+
7
+ def self.group(line_chunks)
8
+ paths = line_chunks.map { |chunk| chunk[:path].to_s.strip }.reject(&:empty?).uniq
9
+ return [] if paths.empty?
10
+
11
+ components = connected_components(paths)
12
+ components.map.with_index(1) do |component, index|
13
+ {
14
+ id: index,
15
+ files: component,
16
+ chunks: line_chunks.select { |chunk| component.include?(chunk[:path]) }
17
+ }
18
+ end
19
+ end
20
+
21
+ def self.connected_components(paths)
22
+ visited = {}
23
+ ordered_components = []
24
+
25
+ paths.each do |root|
26
+ next if visited[root]
27
+
28
+ stack = [root]
29
+ visited[root] = true
30
+ component = []
31
+
32
+ until stack.empty?
33
+ current = stack.pop
34
+ component << current
35
+
36
+ paths.each do |candidate|
37
+ next if visited[candidate]
38
+ next unless connected?(current, candidate)
39
+
40
+ visited[candidate] = true
41
+ stack << candidate
42
+ end
43
+ end
44
+
45
+ ordered_components << component.sort_by { |path| paths.index(path) }
46
+ end
47
+
48
+ ordered_components
49
+ end
50
+ private_class_method :connected_components
51
+
52
+ def self.connected?(left, right)
53
+ return true if primary_namespace(left) == primary_namespace(right)
54
+ return true if logical_stem(left) == logical_stem(right)
55
+
56
+ left_segments = normalized_segments(left)
57
+ right_segments = normalized_segments(right)
58
+ shared_depth = [left_segments.length, right_segments.length].min
59
+
60
+ (0...shared_depth).count { |index| left_segments[index] == right_segments[index] } >= 2
61
+ end
62
+ private_class_method :connected?
63
+
64
+ def self.primary_namespace(path)
65
+ segments = normalized_segments(path)
66
+ return nil if segments.empty?
67
+
68
+ segments.first(2).join('/')
69
+ end
70
+ private_class_method :primary_namespace
71
+
72
+ def self.logical_stem(path)
73
+ normalized = path.to_s
74
+ normalized = normalized.sub(%r{\A(?:lib|spec|test|app|src)/}, '') while normalized.match?(%r{\A(?:lib|spec|test|app|src)/})
75
+ normalized = normalized.sub(/_spec\.[^.]+\z/, '')
76
+ normalized.sub(/\.[^.]+\z/, '')
77
+ end
78
+ private_class_method :logical_stem
79
+
80
+ def self.normalized_segments(path)
81
+ path.to_s.split('/').reject { |segment| segment.empty? || NOISE_DIRECTORIES.include?(segment) }
82
+ end
83
+ private_class_method :normalized_segments
84
+ end
85
+ end
@@ -19,7 +19,7 @@ module Commiti
19
19
  edited = edit_message_until_valid(working_message)
20
20
  if edited.nil?
21
21
  puts "\nEditor did not exit successfully. Commit skipped.\n\n"
22
- return
22
+ return :skipped
23
23
  end
24
24
 
25
25
  working_message = edited
@@ -28,13 +28,13 @@ module Commiti
28
28
  end
29
29
 
30
30
  puts "\nCommit skipped.\n\n"
31
- return
31
+ return :skipped
32
32
  end
33
33
 
34
34
  output = run_stage.call('Writing commit') { Commiti::GitWriter.commit_with_message_file(working_message) }
35
35
  puts output unless output.to_s.strip.empty?
36
36
  puts "\nCommit created.\n\n"
37
- return
37
+ return :committed
38
38
  when :edit
39
39
  edited = edit_message_until_valid(working_message)
40
40
  if edited.nil?
@@ -46,7 +46,7 @@ module Commiti
46
46
  print_message.call(working_message)
47
47
  else
48
48
  puts "\nCommit skipped.\n\n"
49
- return
49
+ return :skipped
50
50
  end
51
51
  end
52
52
  end
@@ -14,7 +14,7 @@ module Commiti
14
14
  puts "\nCurrent git status:\n\n#{status}"
15
15
  return unless Commiti::InteractivePrompt.ask_yes_no('Run git add -A now?', default: :no)
16
16
 
17
- run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all }
17
+ run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all! }
18
18
  puts "\nStaged changes with git add -A.\n"
19
19
  end
20
20
  private_class_method :maybe_stage_changes
@@ -25,7 +25,7 @@ module Commiti
25
25
 
26
26
  if Commiti::InteractivePrompt.ask_yes_no('No staged changes found. Stage all changes now with git add -A?',
27
27
  default: :yes)
28
- run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all }
28
+ run_stage.call('Staging changes (git add -A)') { Commiti::GitWriter.stage_all! }
29
29
  puts "\nStaged changes with git add -A.\n"
30
30
  end
31
31
 
@@ -19,11 +19,28 @@ module Commiti
19
19
  !out.strip.empty?
20
20
  end
21
21
 
22
- def self.stage_all
22
+ def self.stage_all!
23
23
  out, err, status = Open3.capture3('git', 'add', '-A')
24
24
  raise "git add failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
25
25
 
26
- true
26
+ out
27
+ end
28
+
29
+ def self.unstage_all!
30
+ out, err, status = Open3.capture3('git', 'reset')
31
+ raise "git reset failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
32
+
33
+ out
34
+ end
35
+
36
+ def self.stage_files!(paths)
37
+ normalized = Array(paths).map(&:to_s).map(&:strip).reject(&:empty?).uniq
38
+ return '' if normalized.empty?
39
+
40
+ out, err, status = Open3.capture3('git', 'add', '--', *normalized)
41
+ raise "git add failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
42
+
43
+ out
27
44
  end
28
45
 
29
46
  def self.commit_with_message_file(message)
@@ -71,7 +71,9 @@ module Commiti
71
71
  private_class_method :prefilled_url
72
72
 
73
73
  def self.github_like_compare_url(remote:, base_branch:, head_branch:, title:, body:, include_title: true, include_body: true)
74
- query_params = { 'expand' => '1' }
74
+ query_params = {
75
+ github_compare_prefill_param(remote[:provider]) => '1'
76
+ }
75
77
  normalized_title = normalize_title(title)
76
78
  query_params['title'] = normalized_title if include_title && !normalized_title.empty?
77
79
  query_params['body'] = body.to_s if include_body && !body.to_s.empty?
@@ -83,6 +85,11 @@ module Commiti
83
85
  "#{base}/#{path}/compare/#{encode_branch_for_path(base_branch)}...#{encode_branch_for_path(head_branch)}?#{query}"
84
86
  end
85
87
 
88
+ def self.github_compare_prefill_param(provider)
89
+ provider == :github ? 'quick_pull' : 'expand'
90
+ end
91
+ private_class_method :github_compare_prefill_param
92
+
86
93
  def self.gitlab_mr_url(remote:, base_branch:, head_branch:, title:, body:, include_title: true, include_description: true)
87
94
  query_params = {
88
95
  'merge_request[source_branch]' => head_branch,
@@ -8,6 +8,7 @@ module Commiti
8
8
  candidates: 1,
9
9
  base_branch: 'main',
10
10
  no_copy: false,
11
+ auto_split: false,
11
12
  temperature: Commiti::GoogleClient::DEFAULT_TEMPERATURE,
12
13
  timeout_seconds: Commiti::GoogleClient::DEFAULT_TIMEOUT_SECONDS,
13
14
  open_timeout_seconds: Commiti::GoogleClient::DEFAULT_OPEN_TIMEOUT_SECONDS
@@ -22,6 +23,7 @@ module Commiti
22
23
  candidates: integer_or_default(env.fetch('COMMITI_CANDIDATES', nil), DEFAULT_CONFIG[:candidates]),
23
24
  base_branch: present_or_default(env.fetch('COMMITI_BASE_BRANCH', nil), DEFAULT_CONFIG[:base_branch]),
24
25
  no_copy: boolean_or_default(env.fetch('COMMITI_NO_COPY', nil), DEFAULT_CONFIG[:no_copy]),
26
+ auto_split: boolean_or_default(env.fetch('COMMITI_AUTO_SPLIT', nil), DEFAULT_CONFIG[:auto_split]),
25
27
  temperature: float_or_default(env.fetch('COMMITI_MODEL_TEMPERATURE', nil), DEFAULT_CONFIG[:temperature]),
26
28
  timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_TIMEOUT_SECONDS', nil), DEFAULT_CONFIG[:timeout_seconds]),
27
29
  open_timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_OPEN_TIMEOUT_SECONDS', nil),
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commiti
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Setoju
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-18 00:00:00.000000000 Z
11
+ date: 2026-05-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dotenv
@@ -58,6 +58,7 @@ files:
58
58
  - lib/services/diff_summarization/diff_summarizer.rb
59
59
  - lib/services/diff_summarization/fallback_builder.rb
60
60
  - lib/services/flow_context_builder.rb
61
+ - lib/services/git/commit/change_grouping.rb
61
62
  - lib/services/git/commit/commit_execution.rb
62
63
  - lib/services/git/commit/commit_staging.rb
63
64
  - lib/services/git/diff_parser.rb