reviewer 1.1.1 → 1.2.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: f8754795211c60e4aff15d716db73abc4ecbbc55c99afda217ab56180dc74b35
4
- data.tar.gz: 9d7172360cecb1439d2ae513053211027f064786d72ad59f3a4f8029fbbdb557
3
+ metadata.gz: 9e521c12a7d3f686337af0ad6dff4adb7e1c0f6e7ec49e05ed244931bf27d14f
4
+ data.tar.gz: 779c50bbe81345a91f80d31b5b9c269d283fc8e3b0d54f5193e8e01e4ca9b0d3
5
5
  SHA512:
6
- metadata.gz: ebbf843d62775a5aa4c7ead5da403f9a368680a503782433e2674df6643a5b9911802e9663200fb39e184d26cffb25c166af6ceef340c5f8926efb5f7cbbec93
7
- data.tar.gz: e48861c8fe35cf51877298cd97e177705022720f0576e4aa4dc1bd73dd9c4221ca27a676837e888eeff7bf4529e3c5418656ce8b78aba643025cee3a0ad6879e
6
+ metadata.gz: 764931418180d96bc1331f43a67c333b1788169f41584a3603684e1fbd1f801a00a72fc9a578a4052535327256281de299cb90d86419f1438ac4e7edc87bdab0
7
+ data.tar.gz: 4045484668eab50715cb5b1d61961db4ea9377a12c5e5bb1bd097425c7413cde923dc17cc6a1351b11d6f63336ebf1e33bdbbe2d6ccda0d130e447937d179a52
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.0] - 2026-09-24
4
+
5
+ ### Added
6
+
7
+ - `branched` keyword: selects every file that differs from `origin/HEAD` since the current work split
8
+ from it, including uncommitted and new files, so a whole branch can be reviewed before a pull
9
+ request. It never substitutes another branch: when it can't compare against `origin/HEAD`, it
10
+ stops with a usage error that names the fix or shows Git's message.
11
+
12
+ ### Fixed
13
+
14
+ - Report tool failures normally when a tool prints non-ASCII text under a non-UTF-8 locale (such as
15
+ `LC_ALL=C` or an unrecognized `LC_ALL`), or prints bytes that aren't valid UTF-8, instead of crashing
16
+ with an encoding error. Captured output is read as UTF-8 and invalid bytes are replaced.
17
+ - Include files with non-ASCII names in `staged`, `unstaged`, `modified` and `untracked` runs. Git's
18
+ escaped path names previously never matched the real files, so those files were silently skipped,
19
+ and a non-UTF-8 locale could crash the run.
20
+ - Report a mistyped or unsupported option, such as `rvw --list`, as a usage error with exit status 2
21
+ instead of crashing with a backtrace.
22
+ - Describe `modified` accurately in `--capabilities` and `--help`: it covers tracked files changed
23
+ since the last commit and excludes untracked files.
24
+
3
25
  ## [1.1.1] - 2026-09-02
4
26
 
5
27
  ### Fixed
data/docs/usage.md CHANGED
@@ -59,8 +59,16 @@ File selectors resolve paths from Git:
59
59
  |---|---|
60
60
  | `staged` | Staged changes |
61
61
  | `unstaged` | Unstaged changes |
62
- | `modified` | Staged and unstaged changes compared with `HEAD` |
62
+ | `modified` | Tracked files changed since the last commit (staged and unstaged); excludes untracked files |
63
63
  | `untracked` | Untracked, non-ignored files |
64
+ | `branched` | Every file that differs from `origin/HEAD` since this work split from it, including uncommitted and new files |
65
+
66
+ `branched` always compares against `origin/HEAD` and never substitutes another branch. When it
67
+ can't, it stops with a usage error rather than reviewing nothing:
68
+
69
+ - If `origin/HEAD` isn't set, run `git remote set-head origin --auto`.
70
+ - In a shallow clone, such as a default CI checkout, fetch the default branch with its history
71
+ (for example, `fetch-depth: 0`).
64
72
 
65
73
  `failed` selects tools whose last executed review failed and, when available, reuses each tool's
66
74
  stored failed file paths. File-aware tools retry those paths; tools without file support retry their
@@ -73,12 +81,13 @@ Selections compose:
73
81
  ```console
74
82
  rvw rubocop staged
75
83
  rvw -t ruby modified
84
+ rvw rubocop branched
76
85
  rvw tests -f test/reviewer_test.rb
77
86
  ```
78
87
 
79
- Explicit `-f`, `staged`, `unstaged`, `modified`, and `untracked` requests skip tools without a
80
- `files:` configuration instead of running their full-project command. Bare `rvw` remains the full
81
- configured review.
88
+ Explicit `-f`, `staged`, `unstaged`, `modified`, `untracked`, and `branched` requests skip tools
89
+ without a `files:` configuration instead of running their full-project command. Bare `rvw` remains
90
+ the full configured review.
82
91
 
83
92
  The [configuration reference](configuration.md#file-targeting) describes filtering, file-scoped
84
93
  commands, and Minitest/RSpec source-to-test mapping.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Reviewer
6
+ class Arguments
7
+ class Files
8
+ # Selects the files for the `branched` keyword: every file that differs from origin/HEAD since
9
+ # this work split from it, including uncommitted and new files. It compares against origin/HEAD
10
+ # only, so when any git step fails, `missing` says why and no files are selected rather than
11
+ # substituting another branch or reviewing nothing.
12
+ class BranchedFiles
13
+ # The problem, then how to fix it. An unexpected git error has no known fix, so git's own
14
+ # message is shown instead.
15
+ MISSING_MESSAGES = {
16
+ origin_head: [
17
+ "Can't compare against origin/HEAD: origin/HEAD isn't set",
18
+ 'Run `git remote set-head origin --auto`, then try again.'
19
+ ],
20
+ merge_base: [
21
+ "Can't find where this work split from origin/HEAD",
22
+ 'Fetch the default branch with its history (for example, `fetch-depth: 0` in CI), then try again.'
23
+ ],
24
+ git_error: ["Git couldn't list the files that differ from origin/HEAD"]
25
+ }.freeze
26
+
27
+ # Why the files couldn't be selected. `reason` is :origin_head when origin/HEAD isn't set,
28
+ # :merge_base when HEAD shares no history with it (as in a shallow clone), or :git_error for
29
+ # any other git failure, whose message is kept in `detail`.
30
+ Missing = Struct.new(:reason, :detail) do
31
+ # @return [String] what went wrong
32
+ def problem = MISSING_MESSAGES.fetch(reason).first
33
+
34
+ # @return [String, nil] how to fix it, or git's message for an unexpected error
35
+ def fix = MISSING_MESSAGES.fetch(reason)[1] || detail
36
+
37
+ # @return [String] the problem and its fix as one line
38
+ def message = "#{problem}. #{fix}"
39
+ end
40
+
41
+ # @!attribute [r] files
42
+ # @return [Array<String>] the selected paths, empty when `missing` is set
43
+ # @!attribute [r] missing
44
+ # @return [Missing, nil] why the files couldn't be selected, or nil when they were
45
+ attr_reader :files, :missing
46
+
47
+ # Runs every git step immediately so both attributes reflect the same git state
48
+ #
49
+ # @return [self]
50
+ def initialize
51
+ @missing = nil
52
+ @files = select || []
53
+ end
54
+
55
+ private
56
+
57
+ def select
58
+ merge_base = find_merge_base
59
+ changed = merge_base && lookup(%W[diff --name-only #{merge_base}])
60
+ untracked = changed && lookup(%w[ls-files --others --exclude-standard])
61
+ return unless untracked
62
+
63
+ (changed.split("\n") + untracked.split("\n")).reject(&:empty?)
64
+ end
65
+
66
+ def find_merge_base
67
+ lookup(%w[rev-parse --verify --quiet refs/remotes/origin/HEAD], when_absent: :origin_head) &&
68
+ lookup(%w[merge-base HEAD origin/HEAD], when_absent: :merge_base)
69
+ end
70
+
71
+ # Git exits 1 when a ref or merge base doesn't exist. Any other failure is reported with git's
72
+ # own message, since the fix for an absent ref wouldn't apply to it. Output is read as UTF-8
73
+ # with paths unescaped, like the other git keywords.
74
+ def lookup(options, when_absent: :git_error)
75
+ stdout, stderr, status = Open3.capture3('git', '-c', 'core.quotePath=false', '--no-pager', *options)
76
+ case status.exitstatus
77
+ when 0 then Shell::Result.decode(stdout).strip
78
+ when 1 then record_missing(when_absent, stderr)
79
+ else record_missing(:git_error, stderr)
80
+ end
81
+ end
82
+
83
+ # Only an unexpected error keeps git's message; an absent ref or merge base has a known fix
84
+ def record_missing(reason, stderr)
85
+ detail = (Shell::Result.decode(stderr).lines.first&.strip if reason == :git_error)
86
+ @missing = Missing.new(reason, detail)
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'open3'
4
4
 
5
+ require_relative 'files/branched_files'
6
+
5
7
  module Reviewer
6
8
  class Arguments
7
9
  # Generates a Ruby-friendly list (Array) of files to run the command against from the provided
@@ -56,6 +58,15 @@ module Reviewer
56
58
  end
57
59
  alias inspect to_h
58
60
 
61
+ # Why `branched` couldn't select files. `branched` never substitutes another ref, so the
62
+ # caller reports this instead of reviewing nothing.
63
+ #
64
+ # @return [BranchedFiles::Missing, nil] why the files couldn't be selected, or nil when they
65
+ # were or `branched` wasn't requested
66
+ def missing_base
67
+ branched_files.missing if keywords.include?('branched')
68
+ end
69
+
59
70
  private
60
71
 
61
72
  # Combines the sorted list of unique files/paths by merging the explicitly-provided file
@@ -101,15 +112,21 @@ module Reviewer
101
112
  git_files(%w[ls-files --others --exclude-standard])
102
113
  end
103
114
 
104
- # Executes a git command and returns the output as an array of file paths
115
+ def branched = branched_files.files
116
+
117
+ def branched_files = @branched_files ||= BranchedFiles.new
118
+
119
+ # Executes a git command and returns the output as an array of file paths. Git escapes
120
+ # non-ASCII paths unless `core.quotePath` is off, and its output is read as UTF-8 like any
121
+ # other command output so the paths match the real files regardless of locale.
105
122
  # @param options [Array<String>] the git command options
106
123
  #
107
124
  # @return [Array<String>] the output lines from the command
108
125
  def git_files(options)
109
- command = (%w[git --no-pager] + options).join(' ')
126
+ command = (%w[git -c core.quotePath=false --no-pager] + options).join(' ')
110
127
  stdout, stderr, status = Open3.capture3(command)
111
128
 
112
- return stdout.split("\n").reject(&:empty?) if status.success?
129
+ return Shell::Result.decode(stdout).split("\n").reject(&:empty?) if status.success?
113
130
 
114
131
  raise SystemCallError.new("Git Error: #{stderr} (#{command})", status.exitstatus.to_i)
115
132
  rescue SystemCallError => e
@@ -10,7 +10,7 @@ module Reviewer
10
10
  class Keywords
11
11
  # Keywords that resolve to a list of files. `Arguments::Files` implements one method per
12
12
  # entry, so a keyword here without a matching method silently contributes nothing.
13
- FOR_FILES = %w[staged unstaged modified untracked].freeze
13
+ FOR_FILES = %w[staged unstaged modified untracked branched].freeze
14
14
 
15
15
  # Keywords that select tools rather than files. `failed` reads `last_status` from history via
16
16
  # `Tools#failed_from_history`; the per-tool file scope comes from `Command#stored_failed_files`.
@@ -26,8 +26,10 @@ module Reviewer
26
26
  KEYWORD_DESCRIPTIONS = {
27
27
  'staged' => 'Files staged for commit',
28
28
  'unstaged' => 'Files with unstaged changes',
29
- 'modified' => 'All changed files',
29
+ 'modified' => 'Tracked files changed since the last commit (staged and unstaged); excludes untracked files',
30
30
  'untracked' => 'New files not yet tracked',
31
+ 'branched' => 'Every file that differs from origin/HEAD since this work split from it, ' \
32
+ 'including uncommitted and new files',
31
33
  'failed' => 'Tools whose last executed review failed and have not passed since'
32
34
  }.freeze
33
35
 
@@ -38,6 +40,7 @@ module Reviewer
38
40
  SCENARIOS = {
39
41
  before_commit: 'rvw staged',
40
42
  during_development: 'rvw modified',
43
+ before_pull_request: 'rvw branched',
41
44
  full_review: 'rvw'
42
45
  }.freeze
43
46
 
@@ -10,6 +10,8 @@ module Reviewer
10
10
 
11
11
  MISSING_FILES_MESSAGE = 'The --files option requires at least one file or path.'
12
12
 
13
+ BRANCHED_HINT = "'branched' always compares against origin/HEAD and doesn't take a branch name"
14
+
13
15
  attr_reader :output, :printer
14
16
  private :output, :printer
15
17
 
@@ -25,31 +27,35 @@ module Reviewer
25
27
  # Displays warnings for keywords that don't match any tool or git scope
26
28
  # @param unrecognized [Array<String>] the unrecognized keyword strings
27
29
  # @param suggestions [Hash{String => String}] keyword => suggested correction
30
+ # @param hint [String, nil] a closing note about how the request was misread
28
31
  #
29
32
  # @return [void]
30
- def unrecognized_keywords(unrecognized, suggestions)
33
+ def unrecognized_keywords(unrecognized, suggestions, hint = nil)
31
34
  unrecognized.each do |keyword|
32
35
  printer.puts(:warning, "Unrecognized: #{keyword}")
33
36
  suggestion = suggestions[keyword]
34
37
  printer.puts(:muted, " did you mean '#{suggestion}'?") if suggestion
35
38
  end
39
+ printer.puts(:muted, hint) if hint
36
40
  output.newline
37
41
  end
38
42
 
39
43
  # Renders a machine-readable envelope for a name Reviewer cannot honour
40
44
  # @param unrecognized [Array<String>] the names that matched nothing
41
45
  # @param suggestions [Hash] closest known name per unrecognized name
46
+ # @param hint [String, nil] a note about how the request was misread, omitted when nil
42
47
  #
43
48
  # @return [void]
44
- def unrecognized_keywords_json(unrecognized, suggestions)
49
+ def unrecognized_keywords_json(unrecognized, suggestions, hint = nil)
45
50
  payload = {
46
51
  schema_version: Report::SCHEMA_VERSION,
47
52
  state: 'error',
48
53
  error: {
49
54
  code: 'unrecognized_selector',
50
55
  message: "Unrecognized: #{unrecognized.join(', ')}",
51
- suggestions: suggestions
52
- },
56
+ suggestions: suggestions,
57
+ hint: hint
58
+ }.compact,
53
59
  summary: Report.empty_summary,
54
60
  tools: []
55
61
  }
@@ -80,6 +86,32 @@ module Reviewer
80
86
  printer.write_raw("#{JSON.pretty_generate(payload)}\n")
81
87
  end
82
88
 
89
+ # Displays a usage error when `branched` can't find where the work split from origin/HEAD
90
+ # @param missing [Arguments::Files::BranchedFiles::Missing] why the branched files couldn't be selected
91
+ #
92
+ # @return [void]
93
+ def missing_base(missing)
94
+ printer.puts(:warning, missing.problem)
95
+ printer.puts(:muted, missing.fix)
96
+ output.newline
97
+ end
98
+
99
+ # Renders the machine-readable form of the missing base usage error
100
+ # @param missing [Arguments::Files::BranchedFiles::Missing] why the branched files couldn't be selected
101
+ #
102
+ # @return [void]
103
+ def missing_base_json(missing)
104
+ payload = {
105
+ schema_version: Report::SCHEMA_VERSION,
106
+ state: 'error',
107
+ error: { code: 'missing_base', message: missing.message },
108
+ summary: Report.empty_summary,
109
+ tools: []
110
+ }
111
+
112
+ printer.write_raw("#{JSON.pretty_generate(payload)}\n")
113
+ end
114
+
83
115
  # Displays a warning when an unrecognized output format is requested
84
116
  # @param value [String] the invalid format name
85
117
  # @param known [Array<Symbol>] the valid format options
@@ -47,12 +47,18 @@ module Reviewer
47
47
  def run_tools(command_type)
48
48
  return reject_invalid_files_option if arguments.invalid_files_option?
49
49
  return reject_unrecognized_selectors if unrecognized_selectors.any?
50
+ return reject_missing_base if arguments.files.missing_base
50
51
 
51
52
  json_output? ? run_json(command_type) : run_text(command_type)
52
53
  end
53
54
 
54
- def reject_invalid_files_option
55
- json_output? ? formatter.missing_files_option_json : formatter.missing_files_option
55
+ def reject_invalid_files_option = reject_as_usage_error(:missing_files_option)
56
+
57
+ def reject_missing_base = reject_as_usage_error(:missing_base, arguments.files.missing_base)
58
+
59
+ # Renders a usage error through the formatter's text or JSON form, then returns its status
60
+ def reject_as_usage_error(formatter_method, *)
61
+ formatter.public_send(json_output? ? :"#{formatter_method}_json" : formatter_method, *)
56
62
  USAGE_ERROR
57
63
  end
58
64
 
@@ -92,15 +98,11 @@ module Reviewer
92
98
  # attributing that success to a tool that never ran.
93
99
  def reject_unrecognized_selectors
94
100
  names = unrecognized_selectors
95
- suggestions = build_suggestions(names)
96
-
97
- if json_output?
98
- formatter.unrecognized_keywords_json(names, suggestions)
99
- else
100
- formatter.unrecognized_keywords(names, suggestions)
101
- end
101
+ reject_as_usage_error(:unrecognized_keywords, names, build_suggestions(names), unrecognized_selector_hint)
102
+ end
102
103
 
103
- USAGE_ERROR
104
+ def unrecognized_selector_hint
105
+ Session::Formatter::BRANCHED_HINT if arguments.files.keywords.include?('branched')
104
106
  end
105
107
 
106
108
  # Positional selectors and `-t` values are the same request spelled two ways,
@@ -42,12 +42,21 @@ module Reviewer
42
42
  #
43
43
  # @return [self]
44
44
  def initialize(stdout = nil, stderr = nil, status = nil)
45
- @stdout = stdout
46
- @stderr = stderr
45
+ @stdout = self.class.decode(stdout)
46
+ @stderr = self.class.decode(stderr)
47
47
  @status = status
48
48
  @exit_status = status&.exitstatus
49
49
  end
50
50
 
51
+ # Ruby labels command output with the locale's encoding, which is US-ASCII under `LC_ALL=C`
52
+ # or an unrecognized locale. Tools overwhelmingly emit UTF-8, so output is read as UTF-8
53
+ # regardless of locale, and bytes that aren't valid UTF-8 are replaced so that later string
54
+ # operations can't raise. Passthrough output reaches the terminal before this, unchanged.
55
+ # @param text [String, nil] raw output from a command
56
+ #
57
+ # @return [String, nil] valid UTF-8 text
58
+ def self.decode(text) = text && String.new(text, encoding: Encoding::UTF_8).scrub
59
+
51
60
  def exists? = [stdout, stderr, exit_status].compact.any?
52
61
 
53
62
  # Determines if re-running a command is entirely futile. Primarily to help when a command
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reviewer
4
- VERSION = '1.1.1'
4
+ VERSION = '1.2.0'
5
5
  end
data/lib/reviewer.rb CHANGED
@@ -41,6 +41,8 @@ module Reviewer
41
41
  # @return [void] Prints output to the console
42
42
  def review
43
43
  handle_early_exits { exit build_session.review }
44
+ rescue Slop::Error => e
45
+ reject_invalid_option(e.message)
44
46
  end
45
47
 
46
48
  # Runs the `format` command for the specified tools/files for which it is configured.
@@ -48,6 +50,8 @@ module Reviewer
48
50
  # @return [void] Prints output to the console
49
51
  def format
50
52
  handle_early_exits { exit build_session.format }
53
+ rescue Slop::Error => e
54
+ reject_invalid_option(e.message)
51
55
  end
52
56
 
53
57
  # The collection of arguments that were passed via the command line.
@@ -105,6 +109,16 @@ module Reviewer
105
109
  yield
106
110
  end
107
111
 
112
+ # A mistyped or unsupported option is a usage error, not a crash
113
+ # @param message [String] the parser's description of the invalid option
114
+ #
115
+ # @return [void]
116
+ def reject_invalid_option(message)
117
+ output.printer.puts(:warning, message)
118
+ output.printer.puts(:muted, 'Run `rvw --help` for usage.')
119
+ exit Session::USAGE_ERROR
120
+ end
121
+
108
122
  def first_time_setup? = !arguments.invalid_files_option? && !configuration.file.exist?
109
123
 
110
124
  def subcommand?(name) = ARGV.first == name.to_s
@@ -133,8 +147,9 @@ module Reviewer
133
147
  Keywords:
134
148
  staged Files staged for commit
135
149
  unstaged Files with unstaged changes
136
- modified All changed files (staged + unstaged)
150
+ modified Tracked files changed since the last commit
137
151
  untracked New files not yet tracked by git
152
+ branched All files that differ from origin/HEAD, including new ones
138
153
  failed Re-run tools whose last executed review failed
139
154
 
140
155
  Options:
@@ -144,6 +159,7 @@ module Reviewer
144
159
  rvw rubocop Run RuboCop only
145
160
  rvw staged Review staged files across all tools
146
161
  rvw -t security modified Run security-tagged tools on changed files
162
+ rvw branched Review everything this branch changes before a PR
147
163
  rvw tests -f test/user_test.rb Run tests on a specific file
148
164
  rvw failed Re-run tools whose last executed review failed
149
165
  fmt rubocop Auto-fix with RuboCop
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reviewer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Garrett Dimon
@@ -190,6 +190,7 @@ files:
190
190
  - lib/reviewer.rb
191
191
  - lib/reviewer/arguments.rb
192
192
  - lib/reviewer/arguments/files.rb
193
+ - lib/reviewer/arguments/files/branched_files.rb
193
194
  - lib/reviewer/arguments/keywords.rb
194
195
  - lib/reviewer/arguments/options.rb
195
196
  - lib/reviewer/arguments/tags.rb