git-crecord 1.0.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 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