git_spelunk 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
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: