git-status-tree 3.5.1 → 3.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92b497a283f3b3e3c260ea64423ad9350acc0973821ebad9b69fe2368cdf0324
4
- data.tar.gz: 8fec32b7303b4de5e2390655b1d20ee53c4c7197cedba99fa17db348fbe942e8
3
+ metadata.gz: 465d1e069565fd9710b3ee6077ccedb08d94ffaf97b413a6f09fc6c18e24e277
4
+ data.tar.gz: 9a04be3d59a43020c81f251e87c18a8c3246bb63d1bd84873db8ba9aacb05a46
5
5
  SHA512:
6
- metadata.gz: d551f8f7cb67163edde8680402d0092bb590ade73a4c4ec4ad97cf7551937e6d983f71e261eca8c5b27773bb9a51d7782a9c6096d80ef01a2afa4cd3947c74db
7
- data.tar.gz: f1eb272a459af1b52b8c13c59fd1d510ff43c213692ed6159a53e4e468405b96711d3b5b7314acd6d2d45df7ae666c3a289149143381a50ba20d5e3fab659de0
6
+ metadata.gz: f07708afe5c16f2df3131e687ece19887f8ce8321552c5b26c5d3beca3ceab2978111c07ee72081ff2b6cab460f830819002c835146dcf4eacdcaaf6430a5669
7
+ data.tar.gz: eedfc54282fb88c3662f881dcf94d8b2ced92730f77d0e42ad2f3928916361520f769e5a9b06d9c16ea64e9ff6ac7a12887e489a57121536e9818be802a88a42
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.5.1
1
+ 3.7.0
data/bin/git-status-tree CHANGED
@@ -8,7 +8,7 @@ require File.join File.dirname(__FILE__), '../lib/version'
8
8
  options = {}
9
9
 
10
10
  parser = OptionParser.new do |opts|
11
- opts.banner = 'Usage: git-status-tree [options]'
11
+ opts.banner = 'Usage: git-status-tree [options] [<commit> [<commit>] | <commit>..<commit>]'
12
12
 
13
13
  opts.on('-i', '--indent INDENT', Integer, 'Set indentation (2-10 spaces)') do |indent|
14
14
  options[:indent] = indent
@@ -36,9 +36,24 @@ end
36
36
  begin
37
37
  parser.parse!
38
38
  rescue OptionParser::InvalidOption => e
39
- puts "Error: #{e.message}"
40
- puts parser
39
+ warn "Error: #{e.message}"
40
+ warn parser
41
41
  exit 1
42
42
  end
43
43
 
44
- puts GitStatusTree.new(options)
44
+ # Residual ARGV holds the positional commit args (options are consumed by
45
+ # parse!). At most two commits are accepted; a single "A..B" range token counts
46
+ # as one positional and is expanded later in GitStatusTree.
47
+ if ARGV.length > 2
48
+ warn 'Error: git tree accepts at most two commits'
49
+ warn parser
50
+ exit 1
51
+ end
52
+ options[:commits] = ARGV.dup
53
+
54
+ begin
55
+ puts GitStatusTree.new(options)
56
+ rescue GitStatusTree::RevisionError => e
57
+ warn e.message
58
+ exit 1
59
+ end
data/lib/node.rb CHANGED
@@ -11,7 +11,8 @@ class Node
11
11
  include NodeCollapsing
12
12
 
13
13
  class << self
14
- attr_accessor :indent, :collapse_dirs
14
+ # mode is :status (default working-tree view) or :commit (history view).
15
+ attr_accessor :indent, :collapse_dirs, :mode
15
16
  end
16
17
 
17
18
  attr_accessor :status, :name, :children
@@ -129,9 +130,13 @@ class Node
129
130
  end
130
131
  end
131
132
 
132
- def file? = children.nil?
133
+ def file?
134
+ children.nil?
135
+ end
133
136
 
134
- def dir? = !file?
137
+ def dir?
138
+ !file?
139
+ end
135
140
 
136
141
  def valid?
137
142
  file? ? valid_file? : valid_dir?
@@ -168,17 +173,44 @@ class Node
168
173
  end
169
174
  end
170
175
 
171
- def modified? = status.include?('M')
172
- def added? = status.include?('A')
173
- def deleted? = status.include?('D')
174
- def renamed? = status.include?('R')
175
- def copied? = status.include?('C')
176
- def unmerged? = status.include?('U')
177
- def new? = status.include?('?')
178
- def staged? = status.include?('+')
176
+ def modified?
177
+ status.include?('M')
178
+ end
179
+
180
+ def added?
181
+ status.include?('A')
182
+ end
183
+
184
+ def deleted?
185
+ status.include?('D')
186
+ end
187
+
188
+ def renamed?
189
+ status.include?('R')
190
+ end
191
+
192
+ def copied?
193
+ status.include?('C')
194
+ end
195
+
196
+ def unmerged?
197
+ status.include?('U')
198
+ end
199
+
200
+ def new?
201
+ status.include?('?')
202
+ end
203
+
204
+ def staged?
205
+ staged_status?(status)
206
+ end
179
207
 
180
208
  private
181
209
 
210
+ def staged_status?(status)
211
+ status.include?('+')
212
+ end
213
+
182
214
  def validate_name!(name)
183
215
  msg = '"name" must be a String.'
184
216
  raise NodeNameError, msg unless name.is_a? String
@@ -196,13 +228,21 @@ class Node
196
228
  if dir?
197
229
  color_name += BashColor::EMB + name
198
230
  else
199
- color_name += BashColor::G if staged?
200
- color_name += BashColor::R unless staged?
231
+ color_name += status_color(status)
201
232
  color_name += "#{name} (#{status})"
202
233
  end
203
234
  color_name + BashColor::NONE
204
235
  end
205
236
 
237
+ # Commit-mode entries render in a distinct third color (cyan) to signal
238
+ # "history view, not your working tree". Status mode keeps the green=staged /
239
+ # red=dirty palette unchanged. Shared with the collapsed-file renderer.
240
+ def status_color(status)
241
+ return BashColor::C if self.class.mode == :commit
242
+
243
+ staged_status?(status) ? BashColor::G : BashColor::R
244
+ end
245
+
206
246
  def name_valid?
207
247
  name.is_a?(String) &&
208
248
  name.length.positive? &&
@@ -104,7 +104,6 @@ module NodeCollapsing
104
104
  end
105
105
 
106
106
  def color_file_with_status(file_name, status)
107
- color = status.include?('+') ? BashColor::G : BashColor::R
108
- "#{color}#{file_name} (#{status})#{BashColor::NONE}"
107
+ "#{status_color(status)}#{file_name} (#{status})#{BashColor::NONE}"
109
108
  end
110
109
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
3
4
  require File.join File.dirname(__FILE__), '../lib/node'
4
5
  require File.join File.dirname(__FILE__), '../lib/node_collapsing'
5
6
  require File.join File.dirname(__FILE__), '../lib/nodes_collection'
@@ -8,19 +9,22 @@ require File.join File.dirname(__FILE__), '../lib/version'
8
9
 
9
10
  # Main class for generating and displaying git status as a tree structure
10
11
  class GitStatusTree
12
+ # Raised when a positional commit argument is not a valid revision.
13
+ class RevisionError < StandardError; end
14
+
11
15
  attr_reader :files, :nodes, :tree
12
16
 
13
17
  def initialize(options = {})
14
- Node.indent = indent(options)
15
- Node.collapse_dirs = collapse(options)
16
- @files = parse_status(`git status --porcelain -z#{untracked_files(options)}`)
18
+ commits = Array(options[:commits])
19
+ configure_node(options, commits)
20
+ @files = source_files(commits, options)
17
21
  @nodes = files.map { |file| Node.create_from_string file }
18
22
  @tree = nodes.reduce { |a, i| (a + i).nodes[0] }
19
23
  end
20
24
 
21
25
  def to_s
22
26
  if tree.nil?
23
- '(working directory clean)'
27
+ Node.mode == :commit ? '(no changes)' : '(working directory clean)'
24
28
  else
25
29
  tree.to_tree_s
26
30
  end
@@ -28,6 +32,110 @@ class GitStatusTree
28
32
 
29
33
  private
30
34
 
35
+ def configure_node(options, commits)
36
+ Node.indent = indent(options)
37
+ Node.collapse_dirs = collapse(options)
38
+ Node.mode = commits.empty? ? :status : :commit
39
+ end
40
+
41
+ # Dispatch on the number of commit args: no commits keeps the working-tree
42
+ # status view; one or two commits switch to the git diff-tree history view.
43
+ def source_files(commits, options)
44
+ if commits.empty?
45
+ parse_status(`git status --porcelain -z#{untracked_files(options)}`)
46
+ else
47
+ parse_diff_tree(diff_tree_output(commits))
48
+ end
49
+ end
50
+
51
+ def diff_tree_output(commits)
52
+ refs = validate_revs!(commits)
53
+ # -M enables rename detection (no -C: copy detection is off by default in
54
+ # git and is slower/noisier), -r recurses into subtrees, --no-commit-id
55
+ # suppresses the leading SHA line, --root lists every file as added for a
56
+ # root commit (no parent), -z gives unambiguous paths.
57
+ # --diff-merges=first-parent diffs a single merge commit against its first
58
+ # parent (diff-tree skips merges by default, and plain -m diffs against
59
+ # every parent); it is a no-op for a two-commit diff.
60
+ run_git!('diff-tree', '--no-commit-id', '--name-status', '-M', '-r', '--root',
61
+ '--diff-merges=first-parent', '-z', *refs)
62
+ end
63
+
64
+ # Run git with an argv array (no shell, so revisions are never word-split,
65
+ # globbed or interpreted) and raise RevisionError with git's own stderr on
66
+ # failure.
67
+ def run_git!(*args)
68
+ stdout, stderr, status = Open3.capture3('git', *args)
69
+ raise RevisionError, stderr.strip unless status.success?
70
+
71
+ stdout
72
+ end
73
+
74
+ # Translate diff-tree -z output into the porcelain entry strings the node
75
+ # pipeline already understands. The stream is "<status>\0<path>\0 …", with
76
+ # rename/copy entries spanning a triplet "<status>\0<old>\0<new>\0".
77
+ def parse_diff_tree(raw)
78
+ tokens = raw.force_encoding('UTF-8').split("\0")
79
+ files = []
80
+ index = 0
81
+ while index < tokens.length
82
+ status = tokens[index]
83
+ index += 1
84
+ next if status.nil? || status.empty?
85
+
86
+ index = append_diff_entry(files, tokens, index, status[0])
87
+ end
88
+ files
89
+ end
90
+
91
+ # Append a single porcelain entry and return the advanced token index.
92
+ # The status letter goes in the porcelain Y column so Node.status reads it
93
+ # verbatim (no staged "+" suffix), and commit-mode rendering colors it cyan.
94
+ def append_diff_entry(files, tokens, index, code)
95
+ if %w[R C].include?(code)
96
+ old = tokens[index]
97
+ new = tokens[index + 1]
98
+ files << " #{code} #{old} -> #{new}"
99
+ index + 2
100
+ else
101
+ files << " #{code} #{tokens[index]}"
102
+ index + 1
103
+ end
104
+ end
105
+
106
+ # Normalize positional args into resolved SHAs, expanding a single "A..B"
107
+ # token into two endpoints; an omitted side defaults to HEAD, as in git.
108
+ # "A...B" is rejected: diff-tree does not honor symmetric-difference
109
+ # semantics and would silently return nothing.
110
+ def validate_revs!(commits)
111
+ if commits.length == 1 && commits[0].include?('..')
112
+ if commits[0].include?('...')
113
+ raise RevisionError, "fatal: '#{commits[0]}': '...' range is not supported; use 'A..B'"
114
+ end
115
+
116
+ commits = commits[0].split('..', 2).map { |rev| rev.empty? ? 'HEAD' : rev }
117
+ end
118
+ commits.map { |rev| validate_rev!(rev) }
119
+ end
120
+
121
+ # Resolve first, then peel to a commit in a second step: appending ^{commit}
122
+ # to the user's text would break revisions like ":/message", where git
123
+ # treats everything after ":/" as the search pattern.
124
+ def validate_rev!(rev)
125
+ object = rev_parse(rev, rev)
126
+ rev_parse("#{object}^{commit}", rev)
127
+ end
128
+
129
+ def rev_parse(spec, rev)
130
+ stdout, stderr, status = Open3.capture3('git', 'rev-parse', '--verify', '--quiet', '--end-of-options', spec)
131
+ sha = stdout.strip
132
+ return sha if status.success? && !sha.empty?
133
+
134
+ # --quiet silences "bad revision"; any stderr left is a real git error
135
+ # (e.g. not a git repository), so surface it instead.
136
+ raise RevisionError, stderr.strip.empty? ? "fatal: bad revision '#{rev}'" : stderr.strip
137
+ end
138
+
31
139
  # Parse NUL-terminated `git status --porcelain -z` output into porcelain
32
140
  # entry strings. Unlike the default output, -z never quotes or escapes
33
141
  # paths, so names with spaces, non-ASCII characters, quotes, backslashes
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-status-tree
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.1
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wolfgang Teuber
@@ -45,14 +45,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '3.4'
48
+ version: '2.6'
49
49
  required_rubygems_version: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
54
  requirements: []
55
- rubygems_version: 3.6.9
55
+ rubygems_version: 4.0.19
56
56
  specification_version: 4
57
57
  summary: git status in file tree format
58
58
  test_files: []