git_spelunk 0.1.2

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.
data/bin/git-spelunk ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ require 'git_spelunk'
3
+
4
+ file_context = GitSpelunk::FileContext.new(ARGV[0], {:sha => ARGV[1], :line_number => ARGV[2]})
5
+ ui = GitSpelunk::UI.new(file_context)
6
+ ui.run
@@ -0,0 +1,25 @@
1
+ require 'grit'
2
+
3
+ module GitSpelunk
4
+ class File
5
+ def initialize(filename, sha, line=1)
6
+ @filename = filename
7
+ @sha = sha
8
+ @line = line
9
+ end
10
+
11
+ def blame
12
+ [
13
+ ["abcdef", "content"],
14
+ ["abcdef", "content"],
15
+ ["abcdef", "content"],
16
+ ["abcdef", "content"],
17
+ ["abcdef", "content"]
18
+ ["abcdef", "content"]
19
+ ["abcdef", "content"]
20
+ ["abcdef", "content"]
21
+ ["abcdef", "content"]
22
+ ]
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,83 @@
1
+ require 'grit'
2
+ require 'fileutils'
3
+
4
+ module GitSpelunk
5
+ class FileContext
6
+ attr_accessor :line_number
7
+ attr_reader :repo
8
+
9
+ def initialize(file, options = {})
10
+ @sha = options[:sha] || 'HEAD'
11
+ @line_number = options[:line_number] || 1
12
+
13
+ @repo = options.fetch(:repo) do
14
+ repo_directory = find_repo_from_file(file)
15
+ @file = file.sub(%r{^#{repo_directory}/}, '')
16
+ Grit::Repo.new(repo_directory)
17
+ end
18
+
19
+ @file ||= options.fetch(:file)
20
+ @commit_cache = {}
21
+ end
22
+
23
+
24
+ def clone_for_parent_sha(line_number)
25
+ new_sha = sha_for_line(line_number) + "~1"
26
+ GitSpelunk::FileContext.new(@file, {:sha => new_sha, :repo => @repo, :file => @file})
27
+ end
28
+
29
+ def get_line_for_sha_parent(line_number)
30
+ o = GitSpelunk::Offset.new(@repo, @file, sha_for_line(line_number))
31
+ o.line_number_to_parent(@new_to_old[line_number])
32
+ end
33
+
34
+ def find_repo_from_file(file)
35
+ file = './' + file unless file.start_with?('/')
36
+ targets = file.split('/')
37
+ targets.pop
38
+ while !File.directory?(targets.join("/") + "/.git")
39
+ targets.pop
40
+ end
41
+
42
+ if targets.empty?
43
+ nil
44
+ else
45
+ targets.join("/")
46
+ end
47
+ end
48
+
49
+ def get_blame
50
+ @blame_data ||= begin
51
+ @new_to_old = {}
52
+ @line_to_sha = {}
53
+ blame = Grit::Blame.new(@repo, @file, @sha)
54
+ blame.lines.map do |line|
55
+ @new_to_old[line.lineno] = line.oldlineno
56
+ [line.commit.id_abbrev, line.line]
57
+ end
58
+ end
59
+ @blame_data
60
+ end
61
+
62
+ def sha_for_line(line)
63
+ @blame_data[line - 1][0]
64
+ end
65
+
66
+ def get_line_commit_info(line)
67
+ get_blame
68
+ abbrev = sha_for_line(line)
69
+ commit = (@commit_cache[abbrev] ||= @repo.commit(abbrev))
70
+ return nil unless commit
71
+
72
+ author_info = commit.author_string.split(" ")
73
+ tz = author_info.pop
74
+ utc = Time.at(author_info.pop.to_i)
75
+ [
76
+ "commit " + commit.id,
77
+ "Author: " + author_info.join(" "),
78
+ "Date: " + utc.to_s
79
+ ].join("\n") + "\n\n" + " " + commit.short_message
80
+ end
81
+ end
82
+ end
83
+
@@ -0,0 +1,142 @@
1
+ # Given a sha and a line_number in this sha, this module calculates the corresponding line number in sha's parent.
2
+ # 1. It uses git diff for the sha & its parent to get all the diff chunks.
3
+ # 2. It then calculates which chunk given line_number belongs to.
4
+ # 3. Once found the target chunk, it then goes to the sha's line_number in the diff
5
+ # 4. It then calculate parent's line number by ignoring changes for sha in the diff
6
+ #
7
+ # git diff 6d405155..379120f
8
+ # --- a/app/assets/javascripts/lib/user_assume/chat_extension.module.js
9
+ # +++ b/app/assets/javascripts/lib/user_assume/chat_extension.module.js
10
+ # @@ -1,7 +1,6 @@
11
+ # -/*globals ChatLotus*/
12
+ # module.exports = Em.Object.extend({
13
+ # - chatService: ChatLotus.Service,
14
+ # - hasChatEnabled: Em.computed.oneWay('chatService.hasChatEnabled'),
15
+ # + ChatService: ChatLotus.Service,
16
+ # + hasChatEnabled: Em.computed.oneWay('ChatService.hasChatEnabled'),
17
+ # previousAvailablity: false,
18
+ #
19
+ # detach: function() {
20
+ # @@ -19,10 +18,10 @@ module.exports = Em.Object.extend({
21
+ # },
22
+ #
23
+ # _isChatAvailable: function() {
24
+ # - return this.get('chatService.Availability.available');
25
+ # + return this.getPath('ChatService.Availability.available');
26
+ # },
27
+ #
28
+ # _toggleChatAvailability: function() {
29
+ # - this.get('chatService.Availability').toggleAvailability();
30
+ # + this.getPath('ChatService.Availability').toggleAvailability();
31
+ # }
32
+ # });
33
+
34
+
35
+ module GitSpelunk
36
+ require 'grit'
37
+
38
+ class Offset
39
+ attr_reader :repo, :file_name, :sha, :chunks
40
+
41
+ def initialize(repo, file_name, sha)
42
+ @repo = repo
43
+ @file_name = file_name
44
+ @sha = sha
45
+ parent_sha = @repo.commits(@sha)[0].parents[0].id
46
+ @chunks = diff_chunks(@repo.diff(parent_sha, @sha, @file_name))
47
+ end
48
+
49
+ def diff_chunks(diffs)
50
+ return nil if diffs.empty?
51
+ # split it into chunks: [["@@ -10,13 +10,18 @@", diffs], ["@@ -20,13 +20,18 @@", diffs, diff]]
52
+ multiple_chunks = diffs[0].diff.split(/(@@.*?@@.*?\n)/)
53
+ # Discard file name line
54
+ multiple_chunks[1..multiple_chunks.length].each_slice(2).to_a
55
+ end
56
+
57
+ def line_number_to_parent(src_line_number)
58
+ return nil unless @chunks
59
+ chunk = target_chunk(src_line_number)
60
+ chunk_starting_line, chunk_total_lines = src_start_and_total(stats_line(chunk))
61
+ parent_starting_line = parent_start_and_total(stats_line(chunk))[0]
62
+ parent_line_offset = find_parent_line_number(diff_lines(chunk), src_line_number, chunk_starting_line, chunk_total_lines)
63
+ parent_starting_line + parent_line_offset
64
+ end
65
+
66
+ private
67
+
68
+ def target_chunk(line_number)
69
+ chunks.select {|chunk| has_line?(chunk, line_number)}[0]
70
+ end
71
+
72
+ def has_line?(chunk, line_number)
73
+ starting_line, total_lines = src_start_and_total(stats_line(chunk))
74
+ starting_line + total_lines >= line_number
75
+ end
76
+
77
+ def src_start_and_total(line)
78
+ # Get the offset and line number where lines were added
79
+ # @@ -3,10 +3,17 @@ optionally a line\n unchnaged_line_1\n- deleted_line_1\n+ new_line_1"
80
+ line.scan(/\+(.*)@@/)[0][0].split(",").map {|str| str.to_i}
81
+ end
82
+
83
+ def parent_start_and_total(line)
84
+ line.scan(/\-(.*)\+/)[0][0].split(",").map {|str| str.to_i}
85
+ end
86
+
87
+ def find_parent_line_number(lines, src_line_number, src_starting_line, src_number_of_lines)
88
+ target_line_offset = src_line_number - src_starting_line
89
+ current_line_offset = parent_line_offset = diff_index = 0
90
+
91
+ lines.each do |line|
92
+ break if current_line_offset == target_line_offset
93
+
94
+ if src_line?(line)
95
+ current_line_offset += 1
96
+ end
97
+
98
+ if parent_line?(line)
99
+ parent_line_offset += 1
100
+ end
101
+
102
+ diff_index += 1
103
+ end
104
+ # find last contiguous bit of diff
105
+ line = lines[diff_index]
106
+ removals = additions = 0
107
+
108
+ while ["-", "+"].include?(line[0])
109
+ if parent_line?(line)
110
+ removals += 1
111
+ else
112
+ additions += 1
113
+ end
114
+
115
+ diff_index -= 1
116
+ line = lines[diff_index]
117
+ end
118
+
119
+ forward_push = current_line_offset - additions
120
+ forward_push = removals if forward_push > removals # clamp line matching
121
+ parent_line_offset - removals + forward_push
122
+ end
123
+
124
+ def src_line?(line)
125
+ # Src line will either have a "+" or will be an unchanged line
126
+ line[0] != '-'
127
+ end
128
+
129
+ def parent_line?(line)
130
+ # Src line will either have a "-" or will be an unchanged line
131
+ line[0] != '+'
132
+ end
133
+
134
+ def stats_line(chunk)
135
+ chunk[0]
136
+ end
137
+
138
+ def diff_lines(chunk)
139
+ chunk[1].split("\n")
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,153 @@
1
+ require 'curses'
2
+
3
+ ACTIVE_SHA_COLOR=1
4
+ module GitSpelunk
5
+ class UI
6
+ class PagerWindow < Window
7
+ def initialize(height)
8
+ @window = Curses::Window.new(height, Curses.cols, 0, 0)
9
+ @height = height
10
+ @cursor = 1
11
+ @top = 1
12
+ @highlight_sha = true
13
+ end
14
+
15
+ attr_accessor :data, :highlight_sha
16
+ attr_reader :cursor, :top
17
+
18
+ def draw
19
+ @window.clear
20
+ @window.setpos(0,0)
21
+ line_number_width = (data.size + 1).to_s.size
22
+
23
+ active_sha = data[@cursor - 1][0]
24
+
25
+ data[@top - 1,@height].each_with_index do |b, i|
26
+ sha, content = *b
27
+ line_number = i + @top
28
+
29
+ if sha == active_sha && highlight_sha
30
+ @window.attron(Curses::color_pair(ACTIVE_SHA_COLOR))
31
+ end
32
+
33
+ if @cursor == line_number
34
+ with_highlighting { @window.addstr(sha) }
35
+ else
36
+ @window.addstr(sha)
37
+ end
38
+
39
+ @window.addstr(" %*s " % [line_number_width, line_number])
40
+ if @search_term
41
+ content.split(/(#{@search_term})/).each do |t|
42
+ if t == @search_term
43
+ @window.attron(Curses::A_STANDOUT)
44
+ end
45
+ @window.addstr(t[0,line_remainder])
46
+ @window.attroff(Curses::A_STANDOUT)
47
+ end
48
+ else
49
+ @window.addstr(content[0,line_remainder])
50
+ end
51
+ @window.addstr("\n")
52
+ @window.attroff(Curses::color_pair(ACTIVE_SHA_COLOR))
53
+ end
54
+ @window.refresh
55
+ @window.setpos(0,0)
56
+ end
57
+
58
+ attr_accessor :top
59
+
60
+ def search(term, skip_current_line)
61
+ @search_term = term
62
+ return unless term
63
+ save_cursor = @cursor
64
+ search_data = data.map { |d| d[1] }
65
+ initial_position = save_cursor - (skip_current_line ? 0 : 1)
66
+ search_data[initial_position..-1].each_with_index do |d, i|
67
+ if d =~ /#{term}/
68
+ go_to(initial_position + i + 1)
69
+ return
70
+ end
71
+ end
72
+
73
+ search_data[0..initial_position].each_with_index do |d, i|
74
+ if d =~ /#{term}/
75
+ go_to(i + 1)
76
+ return
77
+ end
78
+ end
79
+ end
80
+
81
+ def bufbottom
82
+ @top + (@height - 1)
83
+ end
84
+
85
+ def cursorup
86
+ return if @cursor == 1
87
+ @cursor -= 1
88
+ adjust_top!
89
+ end
90
+
91
+ def cursordown
92
+ return if @cursor >= data.size
93
+ @cursor += 1
94
+ adjust_top!
95
+ end
96
+
97
+ def pageup
98
+ previous_offset = @cursor - @top
99
+ @cursor -= @height / 2
100
+ if @cursor < 1
101
+ @cursor = 1
102
+ end
103
+
104
+ @top = @cursor - previous_offset
105
+ adjust_top!
106
+ end
107
+
108
+ def pagedown
109
+ previous_offset = @cursor - @top
110
+ @cursor += @height / 2
111
+ if @cursor > data.size
112
+ @cursor = data.size
113
+ end
114
+
115
+ @top = @cursor - previous_offset
116
+ adjust_top!
117
+ end
118
+
119
+ def go_top
120
+ @top = @cursor = 1
121
+ end
122
+
123
+ def go_to(l)
124
+ previous_offset = @cursor - @top
125
+ @cursor = l
126
+ @top = @cursor - previous_offset
127
+ adjust_top!
128
+ end
129
+
130
+
131
+ def go_bottom
132
+ @cursor = data.size
133
+ @top = data.size - (@height - 1)
134
+ end
135
+
136
+ def adjust_top!
137
+ if @top < 1
138
+ @top = 1
139
+ end
140
+
141
+ if @top > @cursor
142
+ @top = @cursor
143
+ end
144
+
145
+ while @cursor > bufbottom
146
+ @top += 1
147
+ end
148
+ end
149
+
150
+ end
151
+ end
152
+ end
153
+
@@ -0,0 +1,56 @@
1
+ module GitSpelunk
2
+ class UI
3
+ class RepoWindow < Window
4
+ def initialize(height, offset)
5
+ @window = Curses::Window.new(height, Curses.cols, offset, 0)
6
+ @offset = offset
7
+ @height = height
8
+ @command_mode = false
9
+ @command_buffer = ""
10
+ @content = ""
11
+ end
12
+
13
+ attr_accessor :content, :command_mode, :command_buffer
14
+
15
+ def exit_command_mode!
16
+ self.command_buffer = ""
17
+ self.command_mode = false
18
+ end
19
+
20
+ def draw
21
+ @window.setpos(0,0)
22
+ draw_status_line
23
+ @window.addstr(@content + "\n") if content
24
+ @window.addstr("\n" * (@height - @content.split("\n").size - 2))
25
+
26
+ draw_bottom_line
27
+ @window.refresh
28
+ set_cursor
29
+ end
30
+
31
+ def set_cursor
32
+ Curses::stdscr.setpos(@offset + @height - 1, command_buffer.size + 1)
33
+ end
34
+
35
+ def draw_status_line
36
+ with_highlighting do
37
+ @window.addstr("navigation: j k CTRL-D CTRL-U ")
38
+ @window.addstr("history: [ ] ")
39
+ @window.addstr("search: / ")
40
+ @window.addstr(" " * line_remainder + "\n")
41
+ end
42
+ end
43
+
44
+ def draw_bottom_line
45
+ if command_mode
46
+ @window.addstr(":" + command_buffer)
47
+ @window.addstr(" " * line_remainder)
48
+ else
49
+ with_highlighting do
50
+ @window.addstr(" " * line_remainder + "\n")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ require 'curses'
2
+
3
+ module GitSpelunk
4
+ class UI
5
+ class Window
6
+ def with_highlighting
7
+ @window.attron(Curses::A_STANDOUT)
8
+ yield
9
+ ensure
10
+ @window.attroff(Curses::A_STANDOUT)
11
+ end
12
+
13
+ def line_remainder
14
+ Curses.cols - @window.curx - 1
15
+ end
16
+ end
17
+ end
18
+ end
19
+
@@ -0,0 +1,158 @@
1
+ require 'git_spelunk/ui/window'
2
+ require 'git_spelunk/ui/pager'
3
+ require 'git_spelunk/ui/repo'
4
+ require 'curses'
5
+
6
+ module GitSpelunk
7
+ class UI
8
+
9
+ def initialize(file_context)
10
+ Curses.init_screen
11
+ Curses.start_color
12
+ Curses.raw
13
+ Curses.nonl
14
+ Curses.curs_set(2)
15
+ screen = Curses.stdscr
16
+ screen.refresh
17
+ screen.keypad(1)
18
+ Curses.init_pair(ACTIVE_SHA_COLOR, Curses::COLOR_GREEN, Curses::COLOR_BLACK)
19
+
20
+ calculate_heights!
21
+ @file_context = file_context
22
+ @history = [file_context]
23
+ @pager = PagerWindow.new(@pager_height)
24
+ @pager.data = @file_context.get_blame
25
+
26
+ @repo = RepoWindow.new(@repo_height, @pager_height)
27
+ end
28
+
29
+ def calculate_heights!
30
+ @repo_height = (Curses.lines.to_f * 0.20).to_i
31
+ @pager_height = Curses.lines - @repo_height
32
+ end
33
+
34
+ def run
35
+ @repo.content = @file_context.get_line_commit_info(@pager.cursor)
36
+ pause_thread
37
+ begin
38
+ @pager.draw
39
+ @repo.draw
40
+ @repo.set_cursor
41
+ handle_key(Curses.getch)
42
+ end while true
43
+ end
44
+
45
+ def pause_thread
46
+ Thread.abort_on_exception = true
47
+ Thread.new do
48
+ while true
49
+ if heartbeat_expired? && @last_line != @pager.cursor
50
+ current_line = @pager.cursor
51
+ content = @file_context.get_line_commit_info(current_line)
52
+ if heartbeat_expired? && @pager.cursor == current_line
53
+ @repo.content = content
54
+ @repo.draw
55
+ @last_line = current_line
56
+ else
57
+ @heartbeat = Time.now
58
+ end
59
+ end
60
+ sleep 0.05
61
+ end
62
+ end
63
+ end
64
+
65
+ def heartbeat_expired?
66
+ @heartbeat && (Time.now - @heartbeat).to_f > 0.30
67
+ end
68
+
69
+ def after_navigation
70
+ @pager.highlight_sha = true
71
+ @repo.exit_command_mode!
72
+ end
73
+
74
+ def handle_key(key)
75
+ @heartbeat = Time.now
76
+ case key
77
+ when Curses::KEY_DOWN, 'j'
78
+ @pager.cursordown
79
+ after_navigation
80
+ when Curses::KEY_UP, '-', 'k'
81
+ @pager.cursorup
82
+ after_navigation
83
+ when Curses::KEY_CTRL_D, ' '
84
+ @pager.pagedown
85
+ after_navigation
86
+ when Curses::KEY_CTRL_U
87
+ @pager.pageup
88
+ after_navigation
89
+ when *(0..9).to_a.map(&:to_s)
90
+ @repo.command_mode = true
91
+ @repo.command_buffer += key
92
+ when Curses::KEY_CTRL_M
93
+ if @repo.command_buffer != ''
94
+ @pager.go_to(@repo.command_buffer.to_i)
95
+ end
96
+ after_navigation
97
+ when 'G'
98
+ if @repo.command_buffer != ''
99
+ @pager.go_to(@repo.command_buffer.to_i)
100
+ else
101
+ @pager.go_bottom
102
+ end
103
+ after_navigation
104
+ when '['
105
+ goto = @file_context.get_line_for_sha_parent(@pager.cursor)
106
+ if goto
107
+ @file_context.line_number = @pager.cursor
108
+ @history.push(@file_context)
109
+
110
+ @file_context = @file_context.clone_for_parent_sha(@pager.cursor)
111
+ @pager.data = @file_context.get_blame
112
+ @pager.go_to(goto)
113
+
114
+ # force commit info update
115
+ @last_line = nil
116
+ end
117
+ when ']'
118
+ if @history.last
119
+ @file_context = @history.pop
120
+ @pager.data = @file_context.get_blame
121
+ @pager.go_to(@file_context.line_number)
122
+ @pager.draw
123
+
124
+ # force commit info update
125
+ @last_line = nil
126
+ end
127
+ when 's'
128
+ @heartbeat = nil
129
+ sha = @file_context.sha_for_line(@pager.cursor)
130
+ Curses.close_screen
131
+ system("git -p --git-dir='#{@file_context.repo.path}' show #{sha} | less")
132
+ Curses.stdscr.refresh
133
+ @pager.draw
134
+ @repo.draw
135
+ @pager.highlight_sha = true
136
+ when '/'
137
+ @repo.command_mode = true
138
+ @repo.command_buffer = '/'
139
+ @repo.draw
140
+ @repo.set_cursor
141
+ begin
142
+ line = Curses.getstr
143
+ rescue Interrupt
144
+ @repo.exit_command_mode!
145
+ end
146
+ @search_string = line
147
+ @pager.search(@search_string, false)
148
+ @repo.exit_command_mode!
149
+ when 'n'
150
+ @pager.search(@search_string, true)
151
+ after_navigation
152
+ when 'q'
153
+ exit
154
+ end
155
+ end
156
+ end
157
+ end
158
+
@@ -0,0 +1,4 @@
1
+ require 'debugger'
2
+ require 'git_spelunk/ui'
3
+ require 'git_spelunk/file_context'
4
+ require 'git_spelunk/offset'
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: git_spelunk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Ben Osheroff
9
+ - Saroj Yadav
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2013-11-15 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: grit
17
+ requirement: !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ! '>='
21
+ - !ruby/object:Gem::Version
22
+ version: '0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ! '>='
29
+ - !ruby/object:Gem::Version
30
+ version: '0'
31
+ description: ''
32
+ email:
33
+ - ben@zendesk.com
34
+ - saroj@zendesk.com
35
+ executables:
36
+ - git-spelunk
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - lib/git_spelunk/file.rb
41
+ - lib/git_spelunk/file_context.rb
42
+ - lib/git_spelunk/offset.rb
43
+ - lib/git_spelunk/ui/pager.rb
44
+ - lib/git_spelunk/ui/repo.rb
45
+ - lib/git_spelunk/ui/window.rb
46
+ - lib/git_spelunk/ui.rb
47
+ - lib/git_spelunk.rb
48
+ - bin/git-spelunk
49
+ homepage: ''
50
+ licenses: []
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ none: false
57
+ requirements:
58
+ - - ! '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ! '>='
65
+ - !ruby/object:Gem::Version
66
+ version: 1.3.6
67
+ requirements: []
68
+ rubyforge_project:
69
+ rubygems_version: 1.8.25
70
+ signing_key:
71
+ specification_version: 3
72
+ summary: ''
73
+ test_files: []
74
+ has_rdoc: