Module: CommitGpt::DiffHelpers

Included in:
CommitAi
Defined in:
lib/commitgpt/diff_helpers.rb

Overview

Helper methods for handling git diffs rubocop:disable Metrics/ModuleLength

Constant Summary collapse

LOCK_FILES =

Lock files to exclude from diff but detect changes

%w[Gemfile.lock package-lock.json yarn.lock pnpm-lock.yaml].freeze

Instance Method Summary collapse

Instance Method Details

#detect_lock_file_changesObject



155
156
157
158
159
160
161
162
163
164
165
# File 'lib/commitgpt/diff_helpers.rb', line 155

def detect_lock_file_changes
  # Check both staged and unstaged changes for lock files
  staged_files = `git diff --cached --name-only`.chomp.split("\n")
  unstaged_files = `git diff --name-only`.chomp.split("\n")
  changed_files = (staged_files + unstaged_files).uniq

  updated_locks = LOCK_FILES.select { |lock| changed_files.include?(lock) }
  return nil if updated_locks.empty?

  updated_locks.map { |f| "#{f} updated (dependency changes)" }.join(', ')
end

#git_diffObject



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/commitgpt/diff_helpers.rb', line 10

def git_diff
  exclusions = LOCK_FILES.map { |f| "\":(exclude)#{f}\"" }.join(' ')
  diff_cached = `git diff --cached . #{exclusions}`.chomp
  diff_unstaged = `git diff . #{exclusions}`.chomp

  # Detect lock file changes and build summary
  @lock_file_summary = detect_lock_file_changes

  if !diff_unstaged.empty?
    if diff_cached.empty?
      # Scenario: Only unstaged changes
      choice = prompt_no_staged_changes
      case choice
      when :add_all
        puts '→ Running git add .'.yellow
        system('git add .')
        diff_cached = `git diff --cached . #{exclusions}`.chomp
        if diff_cached.empty?
          puts '✖ Still no changes to commit.'.red
          return nil
        end
      when :exit
        return nil
      end
    else
      # Scenario: Mixed state (some staged, some not)
      puts '⚠ You have both staged and unstaged changes:'.yellow

      staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
      unstaged_files = `git diff --name-status . #{exclusions}`.chomp

      puts "\n  #{'Staged changes:'.green}"
      puts staged_files.gsub(/^/, '    ')

      puts "\n  #{'Unstaged changes:'.red}"
      puts unstaged_files.gsub(/^/, '    ')
      puts ''

      prompt = TTY::Prompt.new
      choice = prompt.select('How to proceed?') do |menu|
        menu.choice 'Include unstaged changes (git add .)', :add_all
        menu.choice 'Use staged changes only', :staged_only
        menu.choice 'Exit', :exit
      end

      case choice
      when :add_all
        puts '→ Running git add .'.yellow
        system('git add .')
        diff_cached = `git diff --cached . #{exclusions}`.chomp
      when :exit
        return nil
      end
    end
  elsif diff_cached.empty?
    # Scenario: No changes at all (staged or unstaged)
    # Check if there are ANY unstaged files (maybe untracked?)
    # git status --porcelain includes untracked files
    git_status = `git status --porcelain`.chomp
    if git_status.empty?
      puts '⚠ No changes to commit. Working tree clean.'.yellow
      return nil
    else
      # Only untracked files? Or ignored files?
      # If diff_unstaged is empty but git status is not, it usually means untracked files.
      # Let's offer to add them too.
      choice = prompt_no_staged_changes
      case choice
      when :add_all
        puts '→ Running git add .'.yellow
        system('git add .')
        diff_cached = `git diff --cached . #{exclusions}`.chomp
      when :exit
        return nil
      end
    end
  end

  diff = diff_cached

  # Prepend lock file summary to diff if present
  diff = "#{@lock_file_summary}\n\n#{diff}" if @lock_file_summary

  if diff.length > diff_len
    choice = prompt_diff_handling(diff.length, diff_len)
    case choice
    when :chunked
      @chunked_mode = true
      puts "→ Smart chunked mode: splitting #{diff.length} chars into ~#{(diff.length.to_f / diff_len).ceil} segments...".yellow
    when :truncate
      puts "→ Truncating diff to #{diff_len} chars...".yellow
      diff = diff[0...diff_len]
    when :unlimited
      puts "→ Using full diff (#{diff.length} chars)...".yellow
    when :exit
      return nil
    end
  end

  diff
end

#prompt_diff_handling(current_len, max_len) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/commitgpt/diff_helpers.rb', line 125

def prompt_diff_handling(current_len, max_len)
  puts "⚠ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
  prompt = TTY::Prompt.new
  begin
    prompt.select('Choose an option:') do |menu|
      menu.choice 'Smart chunked: split into segments and synthesize (recommended)', :chunked
      menu.choice "Use first #{max_len} characters to generate commit message", :truncate
      menu.choice 'Use unlimited characters (may fail or be slow)', :unlimited
      menu.choice 'Exit', :exit
    end
  rescue TTY::Reader::InputInterrupt, Interrupt
    :exit
  end
end

#prompt_no_staged_changesObject



112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/commitgpt/diff_helpers.rb', line 112

def prompt_no_staged_changes
  puts '⚠ No staged changes found (but unstaged/untracked files exist).'.yellow
  prompt = TTY::Prompt.new
  begin
    prompt.select('Choose an option:') do |menu|
      menu.choice "Run 'git add .' to stage all changes", :add_all
      menu.choice 'Exit (stage files manually)', :exit
    end
  rescue TTY::Reader::InputInterrupt, Interrupt
    :exit
  end
end

#split_diff_by_length(diff, max_len) ⇒ Object



140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/commitgpt/diff_helpers.rb', line 140

def split_diff_by_length(diff, max_len)
  chunks = []
  current_chunk = ''

  diff.each_line do |line|
    if current_chunk.length + line.length > max_len && !current_chunk.empty?
      chunks << current_chunk
      current_chunk = ''
    end
    current_chunk += line
  end
  chunks << current_chunk unless current_chunk.empty?
  chunks
end