git-crecord 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: fffb4cbf34ec7e9c44ff7c17f598c2a2164adf03
4
+ data.tar.gz: 396608cc59e9a52e8315c4a35f68827693030d0f
5
+ SHA512:
6
+ metadata.gz: f80d28b331abbb5dfe6387718fc37032f4329dab9dc3a6547108aeb8453139c8139b017891c60b858c41e091bb52bd18bb447531a5a0ea0df84244d9023e1458
7
+ data.tar.gz: 3377110378bbe79cbb61b9d3707171a0b0eb56847bdfcbb6fa92df4679fbd543ce89124d87dd42648d0075c8c301d6fe4f4fc2beaab1eadb8aa257500d9b3023
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ pkg/
2
+ tmp/
3
+ Gemfile.lock
data/.rubocop.yml ADDED
@@ -0,0 +1,20 @@
1
+ Metrics/LineLength:
2
+ Max: 80
3
+
4
+ Style/AlignParameters:
5
+ EnforcedStyle: with_fixed_indentation
6
+
7
+ Style/SpaceInsideHashLiteralBraces:
8
+ EnforcedStyle: no_space
9
+
10
+ Style/SpaceBeforeBlockBraces:
11
+ EnforcedStyle: no_space
12
+
13
+ Style/NumericLiterals:
14
+ MinDigits: 666
15
+
16
+ Documentation:
17
+ Enabled: false
18
+
19
+ AllCops:
20
+ DisplayStyleGuide: true
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ gem 'curses', '~> 1.0'
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016 Maik Brendler
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,50 @@
1
+ # git-crecord
2
+
3
+ Inspred by [crecord mercurial extension](https://bitbucket.org/edgimar/crecord/wiki/Home), git-crecord is an easy way for partially committing/staging of git changes.
4
+
5
+ ![Screenshot](/screenshot.jpg?raw=true)
6
+
7
+ ## Installation
8
+
9
+ TODO
10
+
11
+ ## Usage
12
+
13
+ ```shell
14
+ $ git crecord
15
+ ```
16
+
17
+ Key-bindings:
18
+ ```
19
+ q - quit
20
+ s - stage selection and quit
21
+ c - commit selection and quit
22
+ j / ↓ - down
23
+ k / ↑ - up
24
+ h / ← - collapse hunk
25
+ l / → - expand
26
+ f - toggle fold
27
+ g - go to first line
28
+ G - go to last line
29
+ C-P - up to previous hunk / file
30
+ C-N - down to previous hunk / file
31
+ SPACE - toggle selection
32
+ A - toggle all selections
33
+ ? - display help
34
+ R - force redraw
35
+ ```
36
+
37
+ ## Development
38
+
39
+ ```shell
40
+ $ git clone https://github.com/mbrendler/git-crecord
41
+ $ cd git-crecord
42
+ $ bundle install
43
+ $ ln -s bin/git-crecord /usr/bin/git-crecord
44
+ ```
45
+
46
+ Tests:
47
+ ```shell
48
+ $ bundle exec rake test
49
+ $ bundle exec rake systemtest
50
+ ```
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'bundler/gem_tasks'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.test_files = FileList['test/git_crecord/**/*test.rb']
7
+ end
8
+
9
+ desc 'run system tests'
10
+ task :systemtest do
11
+ sh(File.join(__dir__, 'test/system-test.sh'))
12
+ end
13
+
14
+ task :default # dummy task to build native extension (install curses)
data/bin/git-crecord ADDED
@@ -0,0 +1,4 @@
1
+ #! /usr/bin/env ruby
2
+ require_relative '../lib/git_crecord'
3
+
4
+ exit(GitCrecord.main(ARGV))
data/ext/mkrf_conf.rb ADDED
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+ require 'rubygems/dependency_installer'
3
+
4
+ # This is a hack to not install curses for ruby-2.0.
5
+
6
+ di = Gem::DependencyInstaller.new
7
+
8
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.1.0')
9
+ di.install 'curses', '~>1.0'
10
+ end
@@ -0,0 +1,29 @@
1
+ require_relative 'lib/git_crecord/version'
2
+
3
+ GemSpec = Gem::Specification.new do |spec|
4
+ spec.required_rubygems_version = Gem::Requirement.new('>= 1.3.6')
5
+ spec.platform = Gem::Platform::RUBY
6
+ spec.required_ruby_version = '>= 2.0.0'
7
+ spec.name = 'git-crecord'
8
+ spec.version = GitCrecord::VERSION
9
+ spec.authors = 'Maik Brendler'
10
+ spec.email = 'maik.brendler@invision.de'
11
+ spec.summary = 'Git command to stage/commit hunks the simple way.'
12
+ spec.description = %w(
13
+ This gem adds the git-crecord command.
14
+ It provides a curses UI to stage/commit git-hunks.
15
+ ).join(' ')
16
+ spec.license = 'MIT'
17
+ spec.homepage = 'https://github.com/mbrendler/git-crecord'
18
+ spec.metadata = {
19
+ 'issue_tracker' => 'https://github.com/mbrendler/git-crecord/issues'
20
+ }
21
+ spec.require_paths = %w(lib)
22
+ spec.files = `git ls-files`.split($RS).delete_if{ |f| %r{^(spec|test)/} =~ f }
23
+ spec.test_files = `git ls-files`.split($RS).grep(%r{^(spec|test)/})
24
+ spec.executables = %w(git-crecord)
25
+ spec.has_rdoc = false
26
+ spec.add_development_dependency 'rake', '~> 10.1', '>= 10.1.1'
27
+ spec.add_development_dependency 'minitest', '~> 5.8', '>= 5.8.4'
28
+ spec.extensions << 'ext/mkrf_conf.rb' # install curses dependency
29
+ end
@@ -0,0 +1,81 @@
1
+ require_relative '../ui/color'
2
+
3
+ module GitCrecord
4
+ module Diff
5
+ class Difference
6
+ attr_accessor :expanded
7
+ attr_accessor :y1, :y2
8
+ attr_reader :subs
9
+
10
+ SELECTED_MAP = {
11
+ true => '[X] ',
12
+ false => '[ ] ',
13
+ :partly => '[~] '
14
+ }.freeze
15
+ SELECTION_MARKER_WIDTH = SELECTED_MAP[true].size
16
+
17
+ def initialize
18
+ @subs = []
19
+ end
20
+
21
+ def strings(width)
22
+ to_s.scan(/.{1,#{content_width(width)}}/)
23
+ end
24
+
25
+ def max_height(width)
26
+ width = content_width(width)
27
+ ((to_s.size - 1).abs / width) + 1 + subs.reduce(0) do |a, e|
28
+ a + e.max_height(width)
29
+ end
30
+ end
31
+
32
+ def content_width(width)
33
+ [1, width - x_offset - SELECTION_MARKER_WIDTH].max
34
+ end
35
+
36
+ def selectable?
37
+ true
38
+ end
39
+
40
+ def selectable_subs
41
+ @selectable_subs ||= subs.select(&:selectable?)
42
+ end
43
+
44
+ def selected
45
+ s = selectable_subs.map(&:selected).uniq
46
+ return s[0] if s.size == 1
47
+ :partly
48
+ end
49
+
50
+ def selected=(value)
51
+ selectable_subs.each{ |sub| sub.selected = value }
52
+ end
53
+
54
+ def style(is_highlighted)
55
+ return Curses::A_BOLD | UI::Color.hl if is_highlighted
56
+ Curses::A_BOLD | UI::Color.normal
57
+ end
58
+
59
+ def prefix_style(_is_highlighted)
60
+ UI::Color.normal
61
+ end
62
+
63
+ def prefix(line_number)
64
+ return SELECTED_MAP.fetch(selected) if line_number == 0 && selectable?
65
+ ' ' * SELECTION_MARKER_WIDTH
66
+ end
67
+
68
+ def print(win, line_number, is_highlighted)
69
+ @y1 = line_number + 1
70
+ prefix_style = prefix_style(is_highlighted)
71
+ style = style(is_highlighted)
72
+ strings(win.width).each_with_index do |string, index|
73
+ win.addstr(' ' * x_offset, line_number += 1, attr: prefix_style)
74
+ win.addstr(prefix(index), attr: prefix_style)
75
+ win.addstr(string, attr: style, fill: ' ')
76
+ end
77
+ @y2 = line_number
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,68 @@
1
+ require_relative 'difference'
2
+ require_relative 'hunk'
3
+ require_relative '../ui/color'
4
+
5
+ module GitCrecord
6
+ module Diff
7
+ class File < Difference
8
+ attr_reader :filename_a
9
+ attr_reader :type
10
+
11
+ def initialize(filename_a, filename_b, type: :modified)
12
+ @filename_a = filename_a
13
+ @filename_b = filename_b
14
+ @type = type
15
+ @expanded = false
16
+ super()
17
+ end
18
+
19
+ def to_s
20
+ prefix = {modified: 'M', untracked: '?'}.fetch(type)
21
+ return "#{prefix} #{@filename_a}" if @filename_a == @filename_b
22
+ "#{prefix} #{filename_a} -> #{filename_b}"
23
+ end
24
+
25
+ def info_string
26
+ line_count = subs.reduce(0){ |a, e| e.selectable_subs.size + a }
27
+ " #{subs.size} hunk(s), #{line_count} line(s) changed"
28
+ end
29
+
30
+ def strings(width)
31
+ result = super
32
+ return result unless expanded
33
+ result += info_string.scan(/.{1,#{content_width(width)}}/)
34
+ result << ''
35
+ end
36
+
37
+ def max_height(width)
38
+ super + ((info_string.size - 1).abs / content_width(width)) + 2
39
+ end
40
+
41
+ def x_offset
42
+ 0
43
+ end
44
+
45
+ def <<(hunk)
46
+ subs << Hunk.new(hunk)
47
+ self
48
+ end
49
+
50
+ def add_hunk_line(line)
51
+ subs.last << line
52
+ end
53
+
54
+ def generate_diff
55
+ return unless selected
56
+ [
57
+ "diff --git a/#{@filename_a} b/#{@filename_b}",
58
+ "--- a/#{@filename_a}",
59
+ "+++ b/#{@filename_b}",
60
+ *subs.map(&:generate_diff).compact,
61
+ ''
62
+ ].join("\n")
63
+ end
64
+
65
+ alias prefix_style style
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,49 @@
1
+ require_relative 'difference'
2
+ require_relative 'line'
3
+ require_relative '../ui/color'
4
+
5
+ module GitCrecord
6
+ module Diff
7
+ class Hunk < Difference
8
+ def initialize(head)
9
+ @head = head
10
+ @expanded = true
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ @head
16
+ end
17
+
18
+ def x_offset
19
+ 3
20
+ end
21
+
22
+ def <<(line)
23
+ subs << Line.new(line)
24
+ self
25
+ end
26
+
27
+ def generate_diff
28
+ return nil unless selected
29
+ [generate_header, *subs.map(&:generate_diff).compact].join("\n")
30
+ end
31
+
32
+ def generate_header
33
+ old_start, old_count, new_start, new_count = parse_header
34
+ selectable_subs.each do |sub|
35
+ next if sub.selected
36
+ new_count -= 1 if sub.add?
37
+ new_count += 1 if sub.del?
38
+ end
39
+ "@@ -#{old_start},#{old_count} +#{new_start},#{new_count} @@"
40
+ end
41
+
42
+ def parse_header
43
+ match = @head.match(/@@ -(\d+)(,(\d+))? \+(\d+)(,(\d+))? @@/)
44
+ raise "mismatching hunk-header - '#{@head}'" if match.nil?
45
+ [match[1], match[3] || 1, match[4], match[6] || 1].map(&:to_i)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,57 @@
1
+ require_relative 'difference'
2
+ require_relative '../ui/color'
3
+
4
+ module GitCrecord
5
+ module Diff
6
+ class Line < Difference
7
+ attr_reader :selected
8
+
9
+ def initialize(line)
10
+ @line = line
11
+ @selected = true
12
+ super()
13
+ end
14
+
15
+ def to_s
16
+ @line
17
+ end
18
+
19
+ def x_offset
20
+ 6
21
+ end
22
+
23
+ def add?
24
+ @line.start_with?('+')
25
+ end
26
+
27
+ def del?
28
+ @line.start_with?('-')
29
+ end
30
+
31
+ def selectable?
32
+ add? || del?
33
+ end
34
+
35
+ def selected=(value)
36
+ @selected = selectable? ? value : selected
37
+ end
38
+
39
+ def expanded
40
+ false
41
+ end
42
+
43
+ def generate_diff
44
+ return " #{@line[1..-1]}" if !selected && del?
45
+ return @line if selected
46
+ nil
47
+ end
48
+
49
+ def style(is_highlighted)
50
+ return UI::Color.hl if is_highlighted
51
+ return UI::Color.green if add?
52
+ return UI::Color.red if del?
53
+ UI::Color.normal
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,59 @@
1
+ require_relative 'diff/file'
2
+
3
+ module GitCrecord
4
+ module Diff
5
+ def self.parse(diff)
6
+ files = []
7
+ enum = diff.lines.each
8
+ loop do
9
+ line = enum.next
10
+ line.chomp!
11
+ next files << parse_file_header(line, enum) if file_start?(line)
12
+ next files[-1] << line if hunk_start?(line)
13
+ files[-1].add_hunk_line(line)
14
+ end
15
+ files
16
+ end
17
+
18
+ def self.file_start?(line)
19
+ line.start_with?('diff')
20
+ end
21
+
22
+ def self.hunk_start?(line)
23
+ line.start_with?('@@')
24
+ end
25
+
26
+ def self.parse_file_header(line, enum)
27
+ enum.next # index ...
28
+ enum.next # --- ...
29
+ enum.next # +++ ...
30
+ File.new(*parse_filenames(line))
31
+ end
32
+
33
+ def self.parse_filenames(line)
34
+ line.match(%r{a/(.*) b/(.*)$})[1..2]
35
+ end
36
+
37
+ def self.untracked_files(git_status)
38
+ git_status.lines.select{ |l| l.start_with?('??') }.flat_map do |path|
39
+ path = path.chomp[3..-1]
40
+ ::File.directory?(path) ? untracked_dir(path) : untracked_file(path)
41
+ end.compact
42
+ end
43
+
44
+ def self.untracked_file(filename)
45
+ File.new(filename, filename, type: :untracked).tap do |file|
46
+ file_lines = ::File.readlines(filename)
47
+ file << "@@ -0,0 +1,#{file_lines.size} @@"
48
+ file_lines.each{ |line| file.add_hunk_line("+#{line.chomp}") }
49
+ file.selected = false
50
+ end
51
+ end
52
+
53
+ def self.untracked_dir(path)
54
+ Dir.glob(::File.join(path, '**/*')).map do |filename|
55
+ untracked_file(filename)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,52 @@
1
+ require_relative 'logger'
2
+ require 'open3'
3
+
4
+ module GitCrecord
5
+ module Git
6
+ def self.stage(files)
7
+ selected_files = files.select(&:selected)
8
+ add_files(selected_files.select{ |file| file.type == :untracked })
9
+ diff = selected_files.map(&:generate_diff).join("\n")
10
+ _stage(diff).success?
11
+ end
12
+
13
+ def self._stage(diff)
14
+ cmd = 'git apply --cached --unidiff-zero - '
15
+ content, status = Open3.capture2e(cmd, stdin_data: diff)
16
+ LOGGER.info(cmd)
17
+ LOGGER.info(diff)
18
+ LOGGER.info(diff.lines.size)
19
+ LOGGER.info('stdout/stderr:')
20
+ LOGGER.info(content)
21
+ LOGGER.info("return code: #{status}")
22
+ status
23
+ end
24
+
25
+ def self.add_files(files)
26
+ files.each do |file|
27
+ success = add_file(file.filename_a)
28
+ raise "could not add file #{file.filename_a}" unless success
29
+ end
30
+ end
31
+
32
+ def self.add_file(filename)
33
+ system("git add -N #{filename}")
34
+ end
35
+
36
+ def self.status
37
+ `git status --porcelain`
38
+ end
39
+
40
+ def self.commit
41
+ exec('git commit')
42
+ end
43
+
44
+ def self.diff
45
+ `git diff --no-ext-diff --no-color`
46
+ end
47
+
48
+ def self.toplevel_dir
49
+ `git rev-parse --show-toplevel`.chomp
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,7 @@
1
+ require 'logger'
2
+
3
+ module GitCrecord
4
+ LOGGER = Logger.new(File.new(File.join(ENV['HOME'], '.git-crecord.log'), 'w'))
5
+ LOGGER.formatter = proc{ |_severity, _datetime, _progname, msg| "#{msg}\n" }
6
+ LOGGER.level = Logger::INFO
7
+ end
@@ -0,0 +1,8 @@
1
+
2
+ module GitCrecord
3
+ class QuitAction < Proc
4
+ def ==(other)
5
+ :quit == other
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,27 @@
1
+ require 'curses'
2
+
3
+ module GitCrecord
4
+ module UI
5
+ module Color
6
+ MAP = {
7
+ normal: 1,
8
+ green: 2,
9
+ red: 3,
10
+ hl: 4
11
+ }.freeze
12
+
13
+ def self.init
14
+ Curses.start_color
15
+ Curses.use_default_colors
16
+ Curses.init_pair(MAP[:normal], -1, -1)
17
+ Curses.init_pair(MAP[:green], Curses::COLOR_GREEN, -1)
18
+ Curses.init_pair(MAP[:red], Curses::COLOR_RED, -1)
19
+ Curses.init_pair(MAP[:hl], Curses::COLOR_WHITE, Curses::COLOR_GREEN)
20
+ end
21
+
22
+ MAP.each_pair do |name, number|
23
+ define_singleton_method(name){ Curses.color_pair(number) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,45 @@
1
+ require 'curses'
2
+
3
+ module GitCrecord
4
+ module UI
5
+ module HelpWindow
6
+ CONTENT = <<EOS.freeze
7
+ q - quit
8
+ s - stage selection and quit
9
+ c - commit selection and quit
10
+ j / ↓ - down
11
+ k / ↑ - up
12
+ h / ← - collapse hunk
13
+ l / → - expand
14
+ f - toggle fold
15
+ g - go to first line
16
+ G - go to last line
17
+ C-P - up to previous hunk / file
18
+ C-N - down to previous hunk / file
19
+ SPACE - toggle selection
20
+ A - toggle all selections
21
+ ? - display help
22
+ R - force redraw
23
+ EOS
24
+
25
+ def self.show
26
+ win = Curses::Window.new(height, width, 0, 0)
27
+ win.box('|', '-')
28
+ CONTENT.split("\n").each_with_index do |line, index|
29
+ win.setpos(index + 1, 1)
30
+ win.addstr(line)
31
+ end
32
+ win.getch
33
+ win.close
34
+ end
35
+
36
+ def self.width
37
+ CONTENT.lines.map(&:size).max + 3
38
+ end
39
+
40
+ def self.height
41
+ CONTENT.lines.size + 2
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,172 @@
1
+ require 'curses'
2
+ require_relative 'color'
3
+ require_relative 'help_window'
4
+ require_relative '../git'
5
+ require_relative '../quit_action'
6
+
7
+ module GitCrecord
8
+ module UI
9
+ class HunksWindow
10
+ def initialize(win, files)
11
+ @win = win
12
+ @files = files
13
+ @visibles = @files
14
+ @highlighted = @files[0]
15
+ @scroll_position = 0
16
+
17
+ resize
18
+ end
19
+
20
+ def getch
21
+ @win.getch
22
+ end
23
+
24
+ def width
25
+ @win.maxx
26
+ end
27
+
28
+ def refresh
29
+ @win.refresh(scroll_position, 0, 0, 0, Curses.lines - 1, width)
30
+ end
31
+
32
+ def redraw
33
+ @win.clear
34
+ print_list(@files)
35
+ refresh
36
+ end
37
+
38
+ def resize
39
+ new_width = Curses.cols
40
+ new_height = [Curses.lines, content_height(new_width)].max
41
+ return if width == new_width && @win.maxy == new_height
42
+ @win.resize(new_height, new_width)
43
+ redraw
44
+ end
45
+
46
+ def content_height(width)
47
+ @files.reduce(@files.size){ |a, e| a + e.max_height(width) }
48
+ end
49
+
50
+ def scroll_position
51
+ upper_position = @highlighted.y1 - 3
52
+ if @scroll_position > upper_position
53
+ @scroll_position = upper_position
54
+ elsif @scroll_position <= @highlighted.y2 + 4 - Curses.lines
55
+ @scroll_position = [@highlighted.y2 + 4, @win.maxy].min - Curses.lines
56
+ end
57
+ @scroll_position
58
+ end
59
+
60
+ def move_highlight(to)
61
+ return if to == @highlighted || to.nil?
62
+ from = @highlighted
63
+ @highlighted = to
64
+ from.print(self, from.y1 - 1, false)
65
+ to.print(self, to.y1 - 1, true)
66
+ refresh
67
+ end
68
+
69
+ def addstr(str, y = nil, x = 0, attr: 0, fill: false)
70
+ @win.setpos(y, x) unless y.nil?
71
+ @win.attrset(attr)
72
+ @win.addstr(str)
73
+ fill_size = width - @win.curx
74
+ return unless fill && fill_size > 0
75
+ @win.addstr((fill * fill_size)[0..fill_size])
76
+ end
77
+
78
+ def print_list(list, line_number = -1)
79
+ list.each do |entry|
80
+ line_number = entry.print(self, line_number, entry == @highlighted)
81
+ next unless entry.expanded
82
+ line_number = print_list(entry.subs, line_number)
83
+ addstr('', line_number += 1, fill: '_') if entry.is_a?(Diff::File)
84
+ end
85
+ line_number
86
+ end
87
+
88
+ def update_visibles
89
+ @visibles = @files.each_with_object([]) do |entry, vs|
90
+ vs << entry
91
+ next unless entry.expanded
92
+ entry.selectable_subs.each do |entryy|
93
+ vs << entryy
94
+ vs.concat(entryy.selectable_subs) if entryy.expanded
95
+ end
96
+ end
97
+ end
98
+
99
+ def quit
100
+ :quit
101
+ end
102
+
103
+ def stage
104
+ QuitAction.new{ Git.stage(@files) }
105
+ end
106
+
107
+ def commit
108
+ QuitAction.new{ Git.stage(@files) && Git.commit }
109
+ end
110
+
111
+ def highlight_next
112
+ move_highlight(@visibles[@visibles.index(@highlighted) + 1])
113
+ end
114
+
115
+ def highlight_previous
116
+ move_highlight(@visibles[[@visibles.index(@highlighted) - 1, 0].max])
117
+ end
118
+
119
+ def highlight_first
120
+ move_highlight(@visibles[0])
121
+ end
122
+
123
+ def highlight_last
124
+ move_highlight(@visibles[-1])
125
+ end
126
+
127
+ def highlight_next_hunk
128
+ index = @visibles.index(@highlighted)
129
+ move_highlight(
130
+ @visibles[(index + 1)..-1].find{ |entry| !entry.subs.empty? }
131
+ )
132
+ end
133
+
134
+ def highlight_previous_hunk
135
+ index = @visibles.index(@highlighted)
136
+ move_highlight(
137
+ @visibles[0...index].reverse_each.find{ |entry| !entry.subs.empty? }
138
+ )
139
+ end
140
+
141
+ def collapse
142
+ toggle_fold if !@highlighted.subs.empty? && @highlighted.expanded
143
+ end
144
+
145
+ def expand
146
+ toggle_fold if !@highlighted.subs.empty? && !@highlighted.expanded
147
+ end
148
+
149
+ def toggle_fold
150
+ @highlighted.expanded = !@highlighted.expanded
151
+ update_visibles
152
+ redraw
153
+ end
154
+
155
+ def toggle_selection
156
+ @highlighted.selected = !@highlighted.selected
157
+ redraw
158
+ end
159
+
160
+ def toggle_all_selections
161
+ new_selected = @files[0].selected == false
162
+ @files.each{ |file| file.selected = new_selected }
163
+ redraw
164
+ end
165
+
166
+ def help_window
167
+ HelpWindow.show
168
+ refresh
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,51 @@
1
+ require 'curses'
2
+ require_relative 'ui/color'
3
+ require_relative 'ui/hunks_window'
4
+
5
+ module GitCrecord
6
+ module UI
7
+ ACTIONS = {
8
+ 'q' => :quit,
9
+ 's' => :stage,
10
+ 'c' => :commit,
11
+ 'j' => :highlight_next,
12
+ Curses::KEY_DOWN => :highlight_next,
13
+ 'k' => :highlight_previous,
14
+ Curses::KEY_UP => :highlight_previous,
15
+ 'h' => :collapse,
16
+ Curses::KEY_LEFT => :collapse,
17
+ 'l' => :expand,
18
+ Curses::KEY_RIGHT => :expand,
19
+ 'f' => :toggle_fold,
20
+ 'g' => :highlight_first,
21
+ 'G' => :highlight_last,
22
+ ''.ord => :highlight_next_hunk,
23
+ ''.ord => :highlight_previous_hunk,
24
+ ' ' => :toggle_selection,
25
+ 'A' => :toggle_all_selections,
26
+ '?' => :help_window,
27
+ 'R' => :redraw,
28
+ Curses::KEY_RESIZE => :resize
29
+ }.freeze
30
+
31
+ def self.run(files)
32
+ Curses.init_screen.keypad = true
33
+ Color.init
34
+ Curses.clear
35
+ Curses.noecho
36
+ Curses.curs_set(0)
37
+ run_loop(HunksWindow.new(Curses::Pad.new(1, 1), files))
38
+ ensure
39
+ Curses.close_screen
40
+ end
41
+
42
+ def self.run_loop(win)
43
+ loop do
44
+ c = win.getch
45
+ next if ACTIONS[c].nil?
46
+ quit = win.send(ACTIONS[c])
47
+ break quit if quit == :quit
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,3 @@
1
+ module GitCrecord
2
+ VERSION = '1.0.0'.freeze
3
+ end
@@ -0,0 +1,45 @@
1
+ require_relative 'git_crecord/git'
2
+ require_relative 'git_crecord/diff'
3
+ require_relative 'git_crecord/ui'
4
+ require_relative 'git_crecord/version'
5
+ require_relative 'git_crecord/ui/help_window'
6
+
7
+ module GitCrecord
8
+ def self.main(argv)
9
+ if argv.include?('--version')
10
+ puts VERSION
11
+ true
12
+ elsif argv.include?('--help') || argv.include?('-h')
13
+ help
14
+ true
15
+ else
16
+ run(with_untracked_files: !argv.include?('--no-untracked-files'))
17
+ end
18
+ end
19
+
20
+ def self.run(with_untracked_files: false)
21
+ toplevel_dir = Git.toplevel_dir
22
+ return false if toplevel_dir.empty?
23
+ Dir.chdir(toplevel_dir) do
24
+ files = Diff.parse(Git.diff)
25
+ files.concat(Diff.untracked_files(Git.status)) if with_untracked_files
26
+ return false if files.empty?
27
+ result = UI.run(files)
28
+ return result.call == true if result.respond_to?(:call)
29
+ true
30
+ end
31
+ end
32
+
33
+ def self.help
34
+ puts <<EOS
35
+ usage: git crecord [<options>]'
36
+
37
+ --no-untracked-files -- ignore untracked files
38
+ --version -- show version information'
39
+ -h -- this help message'
40
+
41
+ in-program commands:'
42
+ #{UI::HelpWindow::CONTENT.gsub(/^/, ' ')}
43
+ EOS
44
+ end
45
+ end
data/screenshot.jpg ADDED
Binary file
@@ -0,0 +1,28 @@
1
+ require_relative '../../test_helper'
2
+
3
+ class HunkTest < Minitest::Test
4
+ include GitCrecord::Diff
5
+
6
+ def test_strings
7
+ hunk = Hunk.new('1234567890' * 5)
8
+ expected = %w(12345678901 23456789012 34567890123 45678901234 567890)
9
+ assert_equal(expected, hunk.strings(19))
10
+ end
11
+
12
+ def test_max_height
13
+ assert_equal(1, Hunk.new('').max_height(10))
14
+ assert_equal(1, Hunk.new('1234567890').max_height(18))
15
+ assert_equal(2, Hunk.new('12345678901').max_height(18))
16
+ end
17
+
18
+ def test_parse_header
19
+ assert_equal([1, 2, 3, 4], Hunk.new('@@ -1,2 +3,4 @@').parse_header)
20
+ assert_equal([1, 1, 3, 4], Hunk.new('@@ -1 +3,4 @@').parse_header)
21
+ assert_equal([1, 2, 3, 1], Hunk.new('@@ -1,2 +3 @@').parse_header)
22
+ end
23
+
24
+ def test_parse_header_failure
25
+ hunk = Hunk.new('ugly header')
26
+ assert_raises(RuntimeError){ hunk.parse_header }
27
+ end
28
+ end
@@ -0,0 +1,187 @@
1
+ #! /usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ function assert-equal() {
6
+ local expected=$1
7
+ local actual=$1
8
+ if test "$expected" != "$actual" ; then
9
+ cat << 'EOF'
10
+ expect:
11
+ $expect
12
+ but got:
13
+ $actual
14
+ ____ _
15
+ | _ \ __ _ _ __ (_) ___
16
+ | |_) / _` | '_ \| |/ __|
17
+ | __/ (_| | | | | | (__
18
+ |_| \__,_|_| |_|_|\___|
19
+ EOF
20
+ exit 1
21
+ fi
22
+ }
23
+
24
+ function assert-diff(){
25
+ local expected=$1
26
+ assert-equal "$expected" "$(git diff | grep '^[+-][^+-]')"
27
+ }
28
+
29
+ function assert-status() {
30
+ local expected=$1
31
+ assert-equal "$expected" "$(git status -s)"
32
+ }
33
+
34
+ function run-git-crecord(){
35
+ local keys=$1
36
+ "$EXECUTABLE" <<<"$keys"
37
+ }
38
+
39
+ readonly HERE="$(dirname "$(readlink -m "${BASH_SOURCE[0]}")")"
40
+ readonly TEST_DIR=$HERE/../tmp/__test__
41
+ readonly EXECUTABLE=$HERE/../bin/git-crecord
42
+ readonly REPO_DIR=$TEST_DIR/repo
43
+
44
+ rm -rf "$TEST_DIR"
45
+ mkdir -p "$TEST_DIR"
46
+
47
+ git init "$REPO_DIR" > /dev/null
48
+
49
+ pushd "$REPO_DIR" > /dev/null
50
+
51
+ touch a_file.txt
52
+
53
+ git add a_file.txt
54
+ git ci -m 'add a_file.txt' > /dev/null
55
+
56
+ cat > a_file.txt << 'EOF'
57
+ This is line 1.
58
+ This is the second line.
59
+ This is line 3.
60
+ This is line 4.
61
+ EOF
62
+
63
+
64
+ echo "add all -----------------------------------------------------------------"
65
+ run-git-crecord "s"
66
+ assert-diff ""
67
+
68
+ git reset > /dev/null
69
+
70
+ echo "add first line ----------------------------------------------------------"
71
+ run-git-crecord " lj s"
72
+ assert-diff "+This is the second line.
73
+ +This is line 3.
74
+ +This is line 4."
75
+
76
+ git reset > /dev/null
77
+
78
+ echo "add another line --------------------------------------------------------"
79
+ run-git-crecord " ljjj s"
80
+ assert-diff "+This is line 1.
81
+ +This is the second line.
82
+ +This is line 4."
83
+
84
+
85
+ git ci -a -m "add some lines" > /dev/null
86
+
87
+ sed -i '' '1,3d' a_file.txt
88
+
89
+ echo "delete all lines --------------------------------------------------------"
90
+ run-git-crecord "s"
91
+ assert-diff ""
92
+
93
+ git reset > /dev/null
94
+
95
+ echo "delete one lines --------------------------------------------------------"
96
+ run-git-crecord " ljj s"
97
+ assert-diff "-This is line 1.
98
+ -This is line 3."
99
+
100
+ git reset --hard > /dev/null
101
+
102
+ # add some more lines:
103
+ cat >> a_file.txt << 'EOF'
104
+
105
+ This is line 5.
106
+ This is line 6.
107
+ This is line 7.
108
+ This is line 8.
109
+ This is line 9.
110
+
111
+ This is line 10.
112
+ This is line 11.
113
+ This is line 12.
114
+ EOF
115
+ git ci -a -m "add some more lines" > /dev/null
116
+
117
+ sed -i '' '2s/.*/This is line 2./' a_file.txt
118
+ sed -i '' '12s/.*/This is the tenth line./' a_file.txt
119
+ sed -i '' '13s/.*/This is the eleventh line./' a_file.txt
120
+
121
+ echo "multiple hunks ----------------------------------------------------------"
122
+ run-git-crecord "s"
123
+ assert-diff ""
124
+
125
+ git reset > /dev/null
126
+
127
+ echo "add lines of second hunk ------------------------------------------------"
128
+ run-git-crecord " ljjj sq "
129
+ assert-diff "-This is the second line.
130
+ +This is line 2."
131
+
132
+ git reset > /dev/null
133
+
134
+ echo "add some lines of all hunks ---------------------------------------------"
135
+ run-git-crecord " ljj jj jj j s"
136
+ assert-diff "-This is the second line.
137
+ -This is line 11."
138
+
139
+ git reset > /dev/null
140
+
141
+ echo "run git-crecord in a subdirectory directory -----------------------------"
142
+ mkdir -p "$REPO_DIR"/sub
143
+ pushd "$REPO_DIR"/sub > /dev/null
144
+ run-git-crecord "s"
145
+ assert-diff ""
146
+ popd > /dev/null # "$REPO_DIR"/sub
147
+
148
+ git reset > /dev/null
149
+
150
+ echo "add a untracked file ----------------------------------------------------"
151
+ echo "b_file line 1" > b_file.txt
152
+ run-git-crecord 'AG s'
153
+ git commit -m "add b_file" > /dev/null
154
+ assert-diff '-This is the second line.
155
+ +This is line 2.
156
+ -This is line 10.
157
+ -This is line 11.
158
+ +This is the tenth line.
159
+ +This is the eleventh line.'
160
+
161
+ echo "a not selected file -----------------------------------------------------"
162
+ echo "b_file line 2" >> b_file.txt
163
+ run-git-crecord "j s"
164
+ assert-diff "+b_file line 2"
165
+
166
+ echo "add untracked file from untracked directory -----------------------------"
167
+ echo "a line" > "$REPO_DIR/sub/sub-file.txt"
168
+ run-git-crecord "AG s"
169
+ assert-diff "+b_file line 2"
170
+ assert-status 'M a_file.txt
171
+ M b_file.txt
172
+ A sub/sub-file.txt'
173
+
174
+ echo "test with +++ line ------------------------------------------------------"
175
+ echo "++++" >> b_file.txt
176
+ run-git-crecord "s"
177
+ assert-diff ""
178
+
179
+ popd > /dev/null # $REPO_DIR
180
+
181
+ cat << 'EOF'
182
+ ___ _ __
183
+ / _ \| |/ /
184
+ | | | | ' /
185
+ | |_| | . \
186
+ \___/|_|\_\
187
+ EOF
@@ -0,0 +1,3 @@
1
+ require 'minitest/autorun'
2
+
3
+ require_relative '../lib/git_crecord'
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: git-crecord
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Maik Brendler
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-06-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '10.1'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 10.1.1
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '10.1'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 10.1.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: minitest
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.8'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 5.8.4
43
+ type: :development
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '5.8'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 5.8.4
53
+ description: This gem adds the git-crecord command. It provides a curses UI to stage/commit
54
+ git-hunks.
55
+ email: maik.brendler@invision.de
56
+ executables:
57
+ - git-crecord
58
+ extensions:
59
+ - ext/mkrf_conf.rb
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".gitignore"
63
+ - ".rubocop.yml"
64
+ - Gemfile
65
+ - LICENSE.txt
66
+ - README.md
67
+ - Rakefile
68
+ - bin/git-crecord
69
+ - ext/mkrf_conf.rb
70
+ - git-crecord.gemspec
71
+ - lib/git_crecord.rb
72
+ - lib/git_crecord/diff.rb
73
+ - lib/git_crecord/diff/difference.rb
74
+ - lib/git_crecord/diff/file.rb
75
+ - lib/git_crecord/diff/hunk.rb
76
+ - lib/git_crecord/diff/line.rb
77
+ - lib/git_crecord/git.rb
78
+ - lib/git_crecord/logger.rb
79
+ - lib/git_crecord/quit_action.rb
80
+ - lib/git_crecord/ui.rb
81
+ - lib/git_crecord/ui/color.rb
82
+ - lib/git_crecord/ui/help_window.rb
83
+ - lib/git_crecord/ui/hunks_window.rb
84
+ - lib/git_crecord/version.rb
85
+ - screenshot.jpg
86
+ - test/git_crecord/diff/hunk_test.rb
87
+ - test/system-test.sh
88
+ - test/test_helper.rb
89
+ homepage: https://github.com/mbrendler/git-crecord
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ issue_tracker: https://github.com/mbrendler/git-crecord/issues
94
+ post_install_message:
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 2.0.0
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: 1.3.6
108
+ requirements: []
109
+ rubyforge_project:
110
+ rubygems_version: 2.5.1
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: Git command to stage/commit hunks the simple way.
114
+ test_files:
115
+ - test/git_crecord/diff/hunk_test.rb
116
+ - test/system-test.sh
117
+ - test/test_helper.rb