├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .simplecov ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── exe └── git-fame ├── git_fame.gemspec ├── lib ├── git_fame.rb └── git_fame │ ├── author.rb │ ├── base.rb │ ├── collector.rb │ ├── command.rb │ ├── contribution.rb │ ├── diff.rb │ ├── error.rb │ ├── extension.rb │ ├── filter.rb │ ├── render.rb │ ├── render │ └── extension.rb │ ├── result.rb │ ├── types.rb │ └── version.rb ├── resources └── example.png └── spec ├── collector_spec.rb ├── command_spec.rb ├── factories.rb ├── filter_spec.rb ├── render_spec.rb ├── result_spec.rb └── spec_helper.rb /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/ruby:3.3 2 | 3 | WORKDIR /workspace 4 | 5 | ENV BUNDLE_PATH=/cache/bundle 6 | 7 | RUN apt-get update && \ 8 | apt-get install -y cmake git && \ 9 | apt-get clean && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | RUN git config --global --add safe.directory * 13 | RUN gem install solargraph bundler:2.5.22 gem-release 14 | 15 | COPY Gemfile Gemfile.lock git_fame.gemspec ./ 16 | COPY lib/git_fame/version.rb ./lib/git_fame/version.rb 17 | 18 | RUN bundle install --jobs 4 --retry 3 19 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ruby Dev Container", 3 | "build": { 4 | "context": "..", 5 | "dockerfile": "Dockerfile" 6 | }, 7 | "extensions": [ 8 | "castwide.solargraph", 9 | "rebornix.Ruby" 10 | ], 11 | "settings": { 12 | "solargraph.diagnostics": true 13 | }, 14 | "postCreateCommand": "bundle install", 15 | "mounts": [ 16 | "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached" 17 | ], 18 | "workspaceFolder": "/workspace" 19 | } 20 | 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: bundler 9 | directory: '/' 10 | versioning-strategy: increase 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: git-fame 2 | 3 | on: [push] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | rubocop: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Ruby and dependencies 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | bundler-cache: true 24 | ruby-version: 3.3.0 25 | 26 | - name: Run Rubocop 27 | run: bundle exec rubocop 28 | 29 | rspec: 30 | runs-on: ${{ matrix.os }} 31 | strategy: 32 | matrix: 33 | ruby: ["3.1", "3.2", "3.3"] 34 | os: [ubuntu-latest, macos-latest] 35 | steps: 36 | - name: Check out repository 37 | uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | 41 | - name: Install Ruby and dependencies 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | bundler-cache: true 45 | ruby-version: ${{ matrix.ruby }} 46 | 47 | - name: Install gem locally 48 | run: bundle exec rake install:local 49 | 50 | - name: Run git-fame 51 | run: bundle exec exe/git-fame --log-level debug 52 | 53 | - name: Upload coverage to CodeClimate 54 | if: runner.os != 'macos' 55 | uses: paambaati/codeclimate-action@v9 56 | env: 57 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 58 | CI: true 59 | with: 60 | coverageLocations: ${{ github.workspace }}/coverage/coverage.xml:cobertura 61 | coverageCommand: bundle exec rspec --format RSpec::Github::Formatter 62 | 63 | - name: Run RSpec 64 | if: runner.os == 'macos' 65 | run: bundle exec rspec --format RSpec::Github::Formatter 66 | 67 | devcontainer: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Check out repository 71 | uses: actions/checkout@v4 72 | with: 73 | fetch-depth: 0 74 | 75 | - name: Install Node.js 76 | uses: actions/setup-node@v4 77 | with: 78 | node-version: "20.x" 79 | 80 | - name: Set up DevContainer CLI 81 | run: npm install -g @devcontainers/cli 82 | 83 | - name: Build DevContainer 84 | run: devcontainer build --workspace-folder . 85 | dockerfile: 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: Check out repository 89 | uses: actions/checkout@v4 90 | with: 91 | fetch-depth: 0 92 | 93 | - name: Build Docker image 94 | run: docker build -t git-fame -f .devcontainer/Dockerfile . 95 | 96 | - name: Run tests in Docker container 97 | run: docker run --rm -v $(pwd):/workspace git-fame bundle exec rspec 98 | 99 | release: 100 | runs-on: ubuntu-latest 101 | if: github.ref == 'refs/heads/main' 102 | needs: [rspec, rubocop, devcontainer, dockerfile] 103 | steps: 104 | - name: Check out repository 105 | uses: actions/checkout@v4 106 | 107 | - name: Set up Ruby 108 | uses: ruby/setup-ruby@v1 109 | with: 110 | bundler-cache: true 111 | ruby-version: 3.3.0 112 | 113 | - name: Setup git 114 | run: | 115 | git config --global user.email "actions@github.com" 116 | git config --global user.name "GitHub Actions" 117 | 118 | - name: Install gem-release 119 | run: gem install gem-release 120 | 121 | - name: Install dependencies 122 | run: bundle install 123 | 124 | - name: Increment version 125 | run: gem bump --version patch --tag --skip-ci --release --file lib/git_fame/version.rb 126 | env: 127 | GEM_HOST_API_KEY: ${{ secrets.GEM_HOST_API_KEY }} 128 | 129 | - name: Install dependencies 130 | run: bundle install 131 | env: 132 | BUNDLE_FROZEN: "false" 133 | 134 | - name: Add and commit version bump 135 | run: git commit -a --amend --no-edit 136 | 137 | - name: Push changes 138 | run: | 139 | git push 140 | git push --tags 141 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | InstalledFiles 7 | _yardoc 8 | coverage 9 | doc/ 10 | lib/bundler/man 11 | pkg 12 | rdoc 13 | spec/reports 14 | test/tmp 15 | test/version_tmp 16 | tmp 17 | .byebug_history 18 | .stats.rspec 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | -r spec_helper 2 | -f progress 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rspec 4 | - rubocop-rake 5 | - rubocop-md 6 | 7 | AllCops: 8 | TargetRubyVersion: 3.1 9 | NewCops: enable 10 | Exclude: ['vendor/**/*'] 11 | 12 | Layout/LineLength: 13 | Max: 120 14 | Exclude: ['**/spec/**/*.rb'] 15 | 16 | RSpec/NestedGroups: 17 | Exclude: 18 | - spec/**/* 19 | 20 | RSpec/ExampleLength: 21 | Exclude: 22 | - spec/**/* 23 | 24 | RSpec/ContextWording: 25 | Prefixes: 26 | - when 27 | - with 28 | - without 29 | - if 30 | - unless 31 | - for 32 | - given 33 | 34 | Metrics/MethodLength: 35 | Enabled: false 36 | 37 | Metrics/BlockLength: 38 | Enabled: false 39 | 40 | Metrics/AbcSize: 41 | Max: 40 42 | 43 | RSpec: 44 | Enabled: true 45 | 46 | Style/Documentation: 47 | Enabled: false 48 | 49 | Style/IfUnlessModifier: 50 | Enabled: False 51 | 52 | Layout/EmptyLinesAroundAttributeAccessor: 53 | Enabled: true 54 | 55 | Layout/SpaceAroundMethodCallOperator: 56 | Enabled: true 57 | 58 | Lint/RaiseException: 59 | Enabled: true 60 | 61 | Lint/StructNewOverride: 62 | Enabled: true 63 | 64 | Style/ExponentialNotation: 65 | Enabled: true 66 | 67 | Style/HashEachMethods: 68 | Enabled: true 69 | 70 | Style/HashTransformKeys: 71 | Enabled: true 72 | 73 | Style/HashTransformValues: 74 | Enabled: true 75 | 76 | Style/SlicingWithRange: 77 | Enabled: true 78 | 79 | Style/MultilineBlockChain: 80 | Enabled: false 81 | 82 | Gemspec/RequiredRubyVersion: 83 | Enabled: false 84 | 85 | Lint/AmbiguousBlockAssociation: 86 | Enabled: false 87 | 88 | Style/StringLiterals: 89 | EnforcedStyle: double_quotes 90 | 91 | Lint/EmptyFile: 92 | Enabled: false 93 | 94 | RSpec/MultipleMemoizedHelpers: 95 | Enabled: false 96 | 97 | 98 | Style/ClassAndModuleChildren: 99 | Enabled: false 100 | 101 | RSpec/LeakyConstantDeclaration: 102 | Enabled: false 103 | 104 | Lint/ConstantDefinitionInBlock: 105 | Enabled: false 106 | 107 | RSpec/MultipleExpectations: 108 | Enabled: false 109 | 110 | Style/CaseEquality: 111 | Enabled: false 112 | 113 | RSpec/RepeatedExampleGroupBody: 114 | Enabled: false 115 | 116 | Naming/MethodName: 117 | Enabled: false 118 | 119 | Metrics/ClassLength: 120 | Enabled: false 121 | 122 | Style/SignalException: 123 | Enabled: false 124 | 125 | Style/SymbolProc: 126 | Enabled: false 127 | 128 | RSpec/FilePath: 129 | Enabled: false 130 | 131 | Layout/CaseIndentation: 132 | EnforcedStyle: end 133 | 134 | Lint/ShadowedException: 135 | Enabled: false 136 | 137 | Lint/Void: 138 | Exclude: ["spec/unit/remap/state/extension_spec.rb"] 139 | 140 | Style/SymbolArray: 141 | EnforcedStyle: brackets 142 | 143 | Naming/PredicateName: 144 | Exclude: ["lib/remap/result.rb", "lib/remap/failure.rb"] 145 | 146 | Lint/EmptyClass: 147 | Exclude: ["lib/remap/nothing.rb"] 148 | 149 | Style/MixinUsage: 150 | Enabled: false 151 | 152 | Style/OpenStructUse: 153 | Enabled: false 154 | 155 | Style/Alias: 156 | EnforcedStyle: prefer_alias 157 | 158 | Style/FormatString: 159 | EnforcedStyle: percent 160 | 161 | Style/FormatStringToken: 162 | EnforcedStyle: unannotated 163 | 164 | Style/WordArray: 165 | EnforcedStyle: brackets 166 | 167 | Style/Lambda: 168 | EnforcedStyle: literal 169 | 170 | Layout/SpaceInLambdaLiteral: 171 | EnforcedStyle: require_space 172 | 173 | Style/StabbyLambdaParentheses: 174 | EnforcedStyle: require_no_parentheses 175 | 176 | Lint/UnusedMethodArgument: 177 | Enabled: false 178 | 179 | Lint/EmptyBlock: 180 | Exclude: ["lib/remap/base.rb"] 181 | 182 | Layout/ClassStructure: 183 | ExpectedOrder: 184 | - module_inclusion 185 | - constants 186 | - association 187 | - public_attribute_macros 188 | - public_delegate 189 | - macros 190 | - public_class_methods 191 | - initializer 192 | - public_methods 193 | - protected_attribute_macros 194 | - protected_methods 195 | - private_attribute_macros 196 | - private_delegate 197 | - private_methods 198 | Enabled: true 199 | Categories: 200 | attribute_macros: 201 | - attribute 202 | - option 203 | - param 204 | module_inclusion: 205 | - include 206 | - prepend 207 | - extend 208 | 209 | Layout/MultilineHashKeyLineBreaks: 210 | Enabled: true 211 | 212 | Layout/MultilineMethodArgumentLineBreaks: 213 | Enabled: false 214 | 215 | Layout/RedundantLineBreak: 216 | Enabled: true 217 | 218 | Layout/FirstArrayElementLineBreak: 219 | Enabled: true 220 | 221 | Layout/FirstHashElementLineBreak: 222 | Enabled: true 223 | 224 | Layout/DefEndAlignment: 225 | Enabled: true 226 | 227 | Layout/FirstArrayElementIndentation: 228 | EnforcedStyle: consistent 229 | 230 | Layout/FirstHashElementIndentation: 231 | EnforcedStyle: consistent 232 | 233 | Layout/FirstArgumentIndentation: 234 | EnforcedStyle: consistent 235 | 236 | Style/TrailingCommaInHashLiteral: 237 | EnforcedStyleForMultiline: no_comma 238 | 239 | Layout/HashAlignment: 240 | Enabled: true 241 | 242 | Layout/ClosingParenthesisIndentation: 243 | Enabled: true 244 | 245 | Layout/MultilineMethodCallBraceLayout: 246 | EnforcedStyle: symmetrical 247 | 248 | Layout/MultilineMethodCallIndentation: 249 | EnforcedStyle: indented 250 | 251 | RSpec/AlignLeftLetBrace: 252 | Enabled: false 253 | 254 | RSpec/AlignRightLetBrace: 255 | Enabled: false 256 | 257 | RSpec/RepeatedDescription: 258 | Enabled: false 259 | 260 | Performance/BlockGivenWithExplicitBlock: 261 | Enabled: true 262 | 263 | Naming/MethodParameterName: 264 | Enabled: false 265 | 266 | Metrics/ModuleLength: 267 | Enabled: false 268 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV.key?("CI") 4 | require "simplecov" 5 | require "simplecov-cobertura" 6 | 7 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter 8 | 9 | SimpleCov.start do 10 | add_filter "app/secrets" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :development, :test do 8 | gem "bundler" 9 | gem "pry" 10 | gem "rubocop", "~> 1.42.0" 11 | gem "rubocop-md" 12 | gem "rubocop-performance" 13 | gem "rubocop-rake" 14 | gem "rubocop-rspec" 15 | 16 | gem "factory_bot" 17 | gem "faker" 18 | gem "rake" 19 | gem "rspec" 20 | gem "rspec-github", require: false 21 | gem "rspec-its", require: "rspec/its" 22 | end 23 | 24 | group :test do 25 | gem "simplecov" 26 | gem "simplecov-cobertura" 27 | end 28 | 29 | gem "racc", "~> 1.8" 30 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | git_fame (3.2.19) 5 | activesupport (>= 7, < 9) 6 | dry-initializer (~> 3.0) 7 | dry-struct (~> 1.0) 8 | dry-types (~> 1.0) 9 | neatjson (~> 0.9) 10 | rugged (~> 1.0) 11 | tty-box (~> 0.5) 12 | tty-option (~> 0.2) 13 | tty-screen (~> 0.5) 14 | tty-spinner (~> 0.9) 15 | tty-table (~> 0.9, <= 0.10.0) 16 | zeitwerk (~> 2.0) 17 | 18 | GEM 19 | remote: https://rubygems.org/ 20 | specs: 21 | activesupport (7.0.7.1) 22 | concurrent-ruby (~> 1.0, >= 1.0.2) 23 | i18n (>= 1.6, < 2) 24 | minitest (>= 5.1) 25 | tzinfo (~> 2.0) 26 | ast (2.4.2) 27 | coderay (1.1.3) 28 | concurrent-ruby (1.3.4) 29 | diff-lcs (1.5.0) 30 | docile (1.4.0) 31 | dry-core (1.0.0) 32 | concurrent-ruby (~> 1.0) 33 | zeitwerk (~> 2.6) 34 | dry-inflector (1.0.0) 35 | dry-initializer (3.1.1) 36 | dry-logic (1.5.0) 37 | concurrent-ruby (~> 1.0) 38 | dry-core (~> 1.0, < 2) 39 | zeitwerk (~> 2.6) 40 | dry-struct (1.6.0) 41 | dry-core (~> 1.0, < 2) 42 | dry-types (>= 1.7, < 2) 43 | ice_nine (~> 0.11) 44 | zeitwerk (~> 2.6) 45 | dry-types (1.7.1) 46 | concurrent-ruby (~> 1.0) 47 | dry-core (~> 1.0) 48 | dry-inflector (~> 1.0) 49 | dry-logic (~> 1.4) 50 | zeitwerk (~> 2.6) 51 | equatable (0.5.0) 52 | factory_bot (6.2.1) 53 | activesupport (>= 5.0.0) 54 | faker (3.1.1) 55 | i18n (>= 1.8.11, < 2) 56 | i18n (1.14.6) 57 | concurrent-ruby (~> 1.0) 58 | ice_nine (0.11.2) 59 | json (2.6.3) 60 | method_source (1.0.0) 61 | minitest (5.25.4) 62 | neatjson (0.10.5) 63 | necromancer (0.4.0) 64 | parallel (1.22.1) 65 | parser (3.2.1.1) 66 | ast (~> 2.4.1) 67 | pastel (0.7.2) 68 | equatable (~> 0.5.0) 69 | tty-color (~> 0.4.0) 70 | pry (0.14.2) 71 | coderay (~> 1.1) 72 | method_source (~> 1.0) 73 | racc (1.8.1) 74 | rainbow (3.1.1) 75 | rake (13.0.6) 76 | regexp_parser (2.7.0) 77 | rexml (3.3.9) 78 | rspec (3.12.0) 79 | rspec-core (~> 3.12.0) 80 | rspec-expectations (~> 3.12.0) 81 | rspec-mocks (~> 3.12.0) 82 | rspec-core (3.12.1) 83 | rspec-support (~> 3.12.0) 84 | rspec-expectations (3.12.2) 85 | diff-lcs (>= 1.2.0, < 2.0) 86 | rspec-support (~> 3.12.0) 87 | rspec-github (2.4.0) 88 | rspec-core (~> 3.0) 89 | rspec-its (1.3.0) 90 | rspec-core (>= 3.0.0) 91 | rspec-expectations (>= 3.0.0) 92 | rspec-mocks (3.12.4) 93 | diff-lcs (>= 1.2.0, < 2.0) 94 | rspec-support (~> 3.12.0) 95 | rspec-support (3.12.0) 96 | rubocop (1.42.0) 97 | json (~> 2.3) 98 | parallel (~> 1.10) 99 | parser (>= 3.1.2.1) 100 | rainbow (>= 2.2.2, < 4.0) 101 | regexp_parser (>= 1.8, < 3.0) 102 | rexml (>= 3.2.5, < 4.0) 103 | rubocop-ast (>= 1.24.1, < 2.0) 104 | ruby-progressbar (~> 1.7) 105 | unicode-display_width (>= 1.4.0, < 3.0) 106 | rubocop-ast (1.28.0) 107 | parser (>= 3.2.1.0) 108 | rubocop-md (1.2.0) 109 | rubocop (>= 1.0) 110 | rubocop-performance (1.16.0) 111 | rubocop (>= 1.7.0, < 2.0) 112 | rubocop-ast (>= 0.4.0) 113 | rubocop-rake (0.6.0) 114 | rubocop (~> 1.0) 115 | rubocop-rspec (2.11.1) 116 | rubocop (~> 1.19) 117 | ruby-progressbar (1.13.0) 118 | rugged (1.7.2) 119 | simplecov (0.22.0) 120 | docile (~> 1.1) 121 | simplecov-html (~> 0.11) 122 | simplecov_json_formatter (~> 0.1) 123 | simplecov-cobertura (2.1.0) 124 | rexml 125 | simplecov (~> 0.19) 126 | simplecov-html (0.12.3) 127 | simplecov_json_formatter (0.1.4) 128 | strings (0.1.8) 129 | strings-ansi (~> 0.1) 130 | unicode-display_width (~> 1.5) 131 | unicode_utils (~> 1.4) 132 | strings-ansi (0.2.0) 133 | tty-box (0.5.0) 134 | pastel (~> 0.7.2) 135 | strings (~> 0.1.6) 136 | tty-cursor (~> 0.7) 137 | tty-color (0.4.3) 138 | tty-cursor (0.7.1) 139 | tty-option (0.2.0) 140 | tty-screen (0.6.5) 141 | tty-spinner (0.9.3) 142 | tty-cursor (~> 0.7) 143 | tty-table (0.10.0) 144 | equatable (~> 0.5.0) 145 | necromancer (~> 0.4.0) 146 | pastel (~> 0.7.2) 147 | strings (~> 0.1.0) 148 | tty-screen (~> 0.6.4) 149 | tzinfo (2.0.6) 150 | concurrent-ruby (~> 1.0) 151 | unicode-display_width (1.8.0) 152 | unicode_utils (1.4.0) 153 | zeitwerk (2.6.7) 154 | 155 | PLATFORMS 156 | aarch64-linux 157 | arm64-darwin-22 158 | arm64-darwin-23 159 | arm64-darwin-24 160 | x86_64-darwin-19 161 | x86_64-darwin-20 162 | x86_64-darwin-21 163 | x86_64-linux 164 | 165 | DEPENDENCIES 166 | bundler 167 | factory_bot 168 | faker 169 | git_fame! 170 | pry 171 | racc (~> 1.8) 172 | rake 173 | rspec 174 | rspec-github 175 | rspec-its 176 | rubocop (~> 1.42.0) 177 | rubocop-md 178 | rubocop-performance 179 | rubocop-rake 180 | rubocop-rspec 181 | simplecov 182 | simplecov-cobertura 183 | 184 | BUNDLED WITH 185 | 2.5.22 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Linus Oleander 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-fame [![git-fame](https://github.com/oleander/git-fame-rb/actions/workflows/main.yml/badge.svg)](https://github.com/oleander/git-fame-rb/actions/workflows/main.yml) [![Gem](https://img.shields.io/gem/v/git_fame)](https://rubygems.org/gems/git_fame) [![Test Coverage](https://api.codeclimate.com/v1/badges/2a0fd846e3a7482679ac/test_coverage)](https://codeclimate.com/github/oleander/git-fame-rb/test_coverage) [![Maintainability](https://api.codeclimate.com/v1/badges/2a0fd846e3a7482679ac/maintainability)](https://codeclimate.com/github/oleander/git-fame-rb/maintainability) 2 | 3 | ![git-fame](resources/example.png) 4 | 5 | `git-fame` is a command-line tool that helps you summarize and pretty-print collaborators, based on the number of contributions. 6 | 7 | The statistics are mostly based on the output of `git blame` on the current branch. 8 | `git fame` counts the total number of current lines of code (and files) that were last touched by each author, and prints out these totals, along with the number of commits per author, as a sorted table. 9 | 10 | ## Installation 11 | 12 | `gem install git_fame` 13 | 14 | ## Usage 15 | 16 | ``` shell 17 | Usage: git fame [OPTIONS] [PATH] 18 | 19 | GitFame is a tool to generate a contributor list from git history 20 | 21 | Arguments: 22 | PATH Path or sub path to the git repository 23 | 24 | Options: 25 | -A, --after [DATE] Only changes made after this date 26 | -B, --before [DATE] Only changes made before this date 27 | --branch [NAME] Branch to be used as starting point (default 28 | "HEAD") 29 | -E, --exclude [GLOB] Exclude files matching the given glob pattern 30 | -e, --extensions [EXT] File extensions to be included starting with a 31 | period 32 | -h, --help Print usage 33 | -I, --include [GLOB] Include files matching the given glob pattern 34 | --log-level [LEVEL] Log level (permitted: debug,info,warn,error,fatal) 35 | 36 | Examples: 37 | Include commits made since 2010 38 | git fame --after 2010-01-01 39 | 40 | Include commits made before 2015 41 | git fame --before 2015-01-01 42 | 43 | Include commits made since 2010 and before 2015 44 | git fame --after 2010-01-01 --before 2015-01-01 45 | 46 | Only changes made to the main branch 47 | git fame --branch main 48 | 49 | Only ruby and javascript files 50 | git fame --extensions .rb .js 51 | 52 | Exclude spec files and the README 53 | git fame --exclude */**/*_spec.rb README.md 54 | 55 | Only spec files and markdown files 56 | git fame --include */**/*_spec.rb */**/*.md 57 | 58 | A parent directory of the current directory 59 | git fame ../other/git/repo 60 | ``` 61 | 62 | ## Development 63 | 64 | 1. `git clone https://github.com/oleander/git-fame-rb.git` 65 | 2. `docker build -t git-fame -f .devcontainer/Dockerfile .` 66 | 3. `docker run -it -v $(pwd):/workspace git-fame bundle exec rspec` 67 | 68 | Have a look at `.devcontainer/Dockerfile` and `.github/workflows/main.yml` for more information. 69 | 70 | ## New release 71 | 72 | 1. Update version in `lib/git_fame/version.rb` 73 | 2. `bundle exec rake release` 74 | 3. Done! 75 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /exe/git-fame: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S ruby -W0 2 | # frozen_string_literal: true 3 | 4 | lib = File.expand_path("lib", __dir__) 5 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) 6 | 7 | require "git_fame" 8 | 9 | GitFame::Command.call 10 | -------------------------------------------------------------------------------- /git_fame.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/git_fame/version" 4 | 5 | Gem::Specification.new do |gem| 6 | gem.name = "git_fame" 7 | gem.version = GitFame::VERSION 8 | gem.authors = ["Linus Oleander"] 9 | gem.email = ["oleander@users.noreply.github.com"] 10 | 11 | gem.description = <<~DESC 12 | A command-line tool that helps you summarize and 13 | pretty-print collaborators in a git repository 14 | based on contributions 15 | 16 | Generates stats like: 17 | - Number of files changed by a user 18 | - Number of commits by user 19 | - Lines of code by a user' 20 | DESC 21 | 22 | gem.summary = <<~SUMMARY 23 | git-fame is a command-line tool that helps you summarize 24 | and pretty-print collaborators in a git repository based 25 | on contributions. A Ruby wrapper for git-blame if you will. 26 | SUMMARY 27 | 28 | gem.homepage = "https://github.com/oleander/git-fame-rb" 29 | gem.required_ruby_version = ">= 3.1" 30 | gem.files = Dir["lib/**/*", "exe/*"] 31 | gem.executables << "git-fame" 32 | gem.bindir = "exe" 33 | 34 | gem.add_dependency "activesupport", ">= 7", "< 9" 35 | gem.add_dependency "dry-initializer", "~> 3.0" 36 | gem.add_dependency "dry-struct", "~> 1.0" 37 | gem.add_dependency "dry-types", "~> 1.0" 38 | gem.add_dependency "neatjson", "~> 0.9" 39 | gem.add_dependency "rugged", "~> 1.0" 40 | gem.add_dependency "tty-box", "~> 0.5" 41 | gem.add_dependency "tty-option", "~> 0.2" 42 | gem.add_dependency "tty-screen", "~> 0.5" 43 | gem.add_dependency "tty-spinner", "~> 0.9" 44 | gem.add_dependency "tty-table", "<= 0.10.0", "~> 0.9" 45 | gem.add_dependency "zeitwerk", "~> 2.0" 46 | gem.metadata = { "rubygems_mfa_required" => "true" } 47 | end 48 | -------------------------------------------------------------------------------- /lib/git_fame.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # See: https://github.com/oleander/git-fame-rb/issues/126 4 | require "active_support" 5 | 6 | require "active_support/core_ext/module/delegation" 7 | require "active_support/isolated_execution_state" 8 | require "active_support/core_ext/numeric/time" 9 | require "active_support/core_ext/object/blank" 10 | require "dry/core/memoizable" 11 | require "dry/initializer" 12 | require "dry/struct" 13 | require "dry/types" 14 | require "neatjson" 15 | require "zeitwerk" 16 | require "pathname" 17 | require "rugged" 18 | 19 | module GitFame 20 | Zeitwerk::Loader.for_gem.tap(&:setup) 21 | end 22 | -------------------------------------------------------------------------------- /lib/git_fame/author.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Author < Base 5 | attribute :name, Types::String 6 | attribute :email, Types::String 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/git_fame/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Base < Dry::Struct 5 | schema schema.strict(true) 6 | 7 | attribute? :log_level, Types::Coercible::Symbol.default(:info).enum(:debug, :info, :warn, :error, :fatal, :unknown) 8 | 9 | private 10 | 11 | def say(template, *args) 12 | logger.debug(template % args) 13 | end 14 | 15 | def logger 16 | @logger ||= Logger.new($stdout, level: log_level, progname: self.class.name) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/git_fame/collector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Collector 5 | extend Dry::Initializer 6 | 7 | option :filter, type: Filter 8 | option :diff, type: Types::Any 9 | 10 | # @return [Collector] 11 | def call 12 | Result.new(contributions:) 13 | end 14 | 15 | private 16 | 17 | def contributions 18 | commits = Hash.new { |h, k| h[k] = Set.new } 19 | files = Hash.new { |h, k| h[k] = Set.new } 20 | lines = Hash.new(0) 21 | names = {} 22 | 23 | diff.each do |change| 24 | filter.call(change) do |loc, file, oid, name, email| 25 | commits[email].add(oid) 26 | files[email].add(file) 27 | names[email] = name 28 | lines[email] += loc 29 | end 30 | end 31 | 32 | lines.each_key.map do |email| 33 | Contribution.new({ 34 | lines: lines[email], 35 | commits: commits[email], 36 | files: files[email], 37 | author: { 38 | name: names[email], 39 | email: 40 | } 41 | }) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/git_fame/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/enumerable" 4 | require "active_support/core_ext/object/blank" 5 | require "tty-option" 6 | require "tty-spinner" 7 | 8 | module GitFame 9 | class Command 10 | include TTY::Option 11 | using Extension 12 | 13 | usage do 14 | program "git" 15 | command "fame" 16 | desc "GitFame is a tool to generate a contributor list from git history" 17 | example "Include commits made since 2010", "git fame --after 2010-01-01" 18 | example "Include commits made before 2015", "git fame --before 2015-01-01" 19 | example "Include commits made since 2010 and before 2015", "git fame --after 2010-01-01 --before 2015-01-01" 20 | example "Only changes made to the main branch", "git fame --branch main" 21 | example "Only ruby and javascript files", "git fame --extensions .rb .js" 22 | example "Exclude spec files and the README", "git fame --exclude */**/*_spec.rb README.md" 23 | example "Only spec files and markdown files", "git fame --include */**/*_spec.rb */**/*.md" 24 | example "A parent directory of the current directory", "git fame ../other/git/repo" 25 | end 26 | 27 | option :log_level do 28 | permit ["debug", "info", "warn", "error", "fatal"] 29 | long "--log-level [LEVEL]" 30 | desc "Log level" 31 | end 32 | 33 | option :exclude do 34 | desc "Exclude files matching the given glob pattern" 35 | long "--exclude [GLOB]" 36 | arity zero_or_more 37 | short "-E [BLOB]" 38 | convert :list 39 | end 40 | 41 | option :include do 42 | desc "Include files matching the given glob pattern" 43 | long "--include [GLOB]" 44 | arity zero_or_more 45 | short "-I [BLOB]" 46 | convert :list 47 | end 48 | 49 | option :extensions do 50 | desc "File extensions to be included starting with a period" 51 | arity zero_or_more 52 | long "--extensions [EXT]" 53 | short "-ex [EXT]" 54 | convert :list 55 | 56 | validate -> input do 57 | input.match(/\.\w+/) 58 | end 59 | end 60 | 61 | option :before do 62 | desc "Only changes made after this date" 63 | long "--before [DATE]" 64 | short "-B [DATE]" 65 | validate -> input do 66 | Types::Params::DateTime.valid?(input) 67 | end 68 | end 69 | 70 | option :after do 71 | desc "Only changes made before this date" 72 | long "--after [DATE]" 73 | short "-A [DATE]" 74 | 75 | validate -> input do 76 | Types::Params::DateTime.valid?(input) 77 | end 78 | end 79 | 80 | argument :path do 81 | desc "Path or sub path to the git repository" 82 | default { Dir.pwd } 83 | optional 84 | 85 | validate -> path do 86 | File.directory?(path) 87 | end 88 | end 89 | 90 | option :branch do 91 | desc "Branch to be used as starting point" 92 | long "--branch [NAME]" 93 | default "HEAD" 94 | end 95 | 96 | flag :version do 97 | desc "Current version" 98 | long "--version" 99 | short "-v" 100 | end 101 | 102 | flag :help do 103 | desc "Print usage" 104 | long "--help" 105 | short "-h" 106 | end 107 | 108 | def self.call(argv = ARGV) 109 | cmd = new 110 | cmd.parse(argv, raise_on_parse_error: true) 111 | cmd.run 112 | rescue TTY::Option::InvalidParameter, TTY::Option::InvalidArgument => e 113 | abort e.message 114 | end 115 | 116 | def run 117 | if params[:help] 118 | puts help 119 | exit 120 | end 121 | 122 | if params[:version] 123 | puts "git-fame v#{GitFame::VERSION}" 124 | exit 125 | end 126 | 127 | thread = spinner.run do 128 | Render.new(result:, **options(:branch)) 129 | end 130 | 131 | thread.value.call 132 | rescue Dry::Struct::Error => e 133 | abort e.message 134 | rescue Interrupt 135 | exit 136 | end 137 | 138 | private 139 | 140 | def filter 141 | Filter.new(**params.to_h.compact_blank.except(:branch)) 142 | end 143 | 144 | def spinner 145 | @spinner ||= TTY::Spinner.new("[:spinner] git-fame is crunching the numbers, hold on ...", interval: 1) 146 | end 147 | 148 | def repo 149 | Rugged::Repository.discover(params[:path]) 150 | end 151 | 152 | def collector 153 | Collector.new(filter:, diff:, **options) 154 | end 155 | 156 | def diff 157 | Diff.new(commit:, **options) 158 | end 159 | 160 | def options(*args) 161 | params.to_h.only(*args, :log_level).compact_blank 162 | end 163 | 164 | def commit 165 | repo.rev_parse(params[:branch]) 166 | end 167 | 168 | def result 169 | collector.call 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/git_fame/contribution.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Contribution < Base 5 | attribute :lines, Types::Integer 6 | attribute :commits, Types::Set 7 | attribute :files, Types::Set 8 | attribute :author, Author 9 | 10 | delegate :name, :email, to: :author 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/git_fame/diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Diff < Base 5 | include Enumerable 6 | 7 | attribute :commit, Types::Any 8 | delegate :tree, to: :commit 9 | delegate :repo, to: :tree 10 | 11 | # @yield [Hash] 12 | # 13 | # @return [void] 14 | def each(&) 15 | tree.walk(:preorder).each do |root, entry| 16 | case entry 17 | in { type: :blob, name: file, oid: } 18 | Rugged::Blame.new(repo, root + file, newest_commit: commit).each(&) 19 | in { type: type, name: file } 20 | say("Ignore type [%s] in for %s", type, root + file) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/git_fame/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Error < StandardError; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/git_fame/extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | module Extension 5 | refine Hash do 6 | # Exclude keys from a Hash 7 | # 8 | # @param [Array] keys 9 | # 10 | # @return [Hash] 11 | def only(...) 12 | dup.extract!(...) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/git_fame/filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Filter < Base 5 | OPT = File::FNM_EXTGLOB | File::FNM_DOTMATCH | File::FNM_CASEFOLD | File::FNM_PATHNAME 6 | 7 | attribute? :before, Types::JSON::DateTime 8 | attribute? :after, Types::JSON::DateTime 9 | attribute? :extensions, Types::Set 10 | attribute? :include, Types::Set 11 | attribute? :exclude, Types::Set 12 | 13 | schema schema.strict(false) 14 | 15 | # Invokes block if hunk is valid 16 | # 17 | # @param hunk [Hash] 18 | # 19 | # @yieldparam lines [Integer] 20 | # @yieldparam orig_path [Pathname] 21 | # @yieldparam oid [String] 22 | # @yieldparam name [String] 23 | # @yieldparam email [String] 24 | # 25 | # @return [void] 26 | def call(hunk, &block) 27 | case [hunk, attributes] 28 | in [{ orig_path: path, final_signature: { time: created_at } }, { after: }] unless created_at > after 29 | say("File %s ignored due to [created > after] (%p > %p)", path, created_at, after) 30 | in [{ orig_path: path, final_signature: { time: created_at } }, { before: }] unless created_at < before 31 | say("File %s ignored due to [created < before] (%p < %p)", path, created_at, before) 32 | in [{ orig_path: path}, { exclude: excluded }] if excluded.any? { File.fnmatch?(_1, path, OPT) } 33 | say("File %s excluded by [exclude] (%p)", path, excluded) 34 | in [{ orig_path: path }, { include: included }] unless included.any? { File.fnmatch?(_1, path, OPT) } 35 | say("File %s excluded by [include] (%p)", path, included) 36 | in [{ orig_path: path }, { extensions: }] unless extensions.any? { File.extname(path) == _1 } 37 | say("File %s excluded by [extensions] (%p)", path, extensions) 38 | in [{final_signature: { name:, email:}, final_commit_id: oid, lines_in_hunk: lines, orig_path: path}, Hash] 39 | block[lines, path, oid, name, email] 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/git_fame/render.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tty-screen" 4 | require "tty-table" 5 | require "tty-box" 6 | require "erb" 7 | 8 | module GitFame 9 | class Render < Base 10 | FIELDS = [:name, :email, :lines, :commits, :files, :dist].map(&:to_s).freeze 11 | 12 | attribute :branch, Types::String 13 | attribute :result, Result 14 | delegate_missing_to :result 15 | 16 | using Extension 17 | 18 | # Renders to stdout 19 | # 20 | # @return [void] 21 | def call 22 | table = TTY::Table.new(header: FIELDS) 23 | width = TTY::Screen.width 24 | 25 | contributions.reverse_each do |c| 26 | table << [c.name, c.email, c.lines.f, c.commits.count.f, c.files.count.f, c.dist(self)] 27 | end 28 | 29 | print table.render(:unicode, width:, resize: true, alignment: [:center]) 30 | end 31 | 32 | private 33 | 34 | def contributions 35 | result.contributions.sort_by(&:lines) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/git_fame/render/extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/dependencies/autoload" 4 | require "active_support/number_helper" 5 | 6 | module GitFame 7 | class Render 8 | module Extension 9 | refine Integer do 10 | def f 11 | ActiveSupport::NumberHelper.number_to_delimited(self, delimiter: " ") 12 | end 13 | end 14 | 15 | refine Contribution do 16 | def dist(result) 17 | l = lines.to_f / result.lines 18 | c = commits.count.to_f / result.commits.count 19 | f = files.count.to_f / result.files.count 20 | 21 | "%0.1f%% / %0.1f%% / %0.1f%%" % [l * 100, c * 100, f * 100] 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/git_fame/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | class Result < Base 5 | attribute :contributions, Types.Array(Contribution) 6 | 7 | # @return [Array] 8 | def authors 9 | contributions.map(&:author) 10 | end 11 | 12 | # @return [Array] 13 | def commits 14 | contributions.flat_map do |c| 15 | c.commits.to_a 16 | end 17 | end 18 | 19 | # @return [Array] 20 | def files 21 | contributions.flat_map do |c| 22 | c.files.to_a 23 | end 24 | end 25 | 26 | # @return [Integer] 27 | def lines 28 | contributions.sum(&:lines) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/git_fame/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | 5 | module GitFame 6 | module Types 7 | include Dry::Types() 8 | 9 | Set = Instance(Set).constructor(&:to_set) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/git_fame/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitFame 4 | VERSION = "3.2.19" 5 | end 6 | -------------------------------------------------------------------------------- /resources/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oleander/git-fame-rb/571d92feb9b353041d0cf6af2cb7a7accc284d20/resources/example.png -------------------------------------------------------------------------------- /spec/collector_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe GitFame::Collector do 4 | describe "#call" do 5 | subject(:result) { collector.call } 6 | 7 | let(:collector) { build(:collector) } 8 | 9 | its("lines") { is_expected.to eq(result.contributions.sum(&:lines)) } 10 | its("authors") { is_expected.to be_present } 11 | its("commits") { is_expected.to be_present } 12 | its("files") { is_expected.to be_present } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe GitFame::Command do 4 | let(:args) { ["--after", "2010-01-01", "--before", "2020-01-01", "--branch", "HEAD"] } 5 | 6 | it "outputs to stdout" do 7 | expect { described_class.call(args) }.to output(/email|name/).to_stdout 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | sequence(:ext) { [".rb", ".js", ".html", ".css", ".md"].sample } 5 | sequence(:file) { Faker::File.file_name(ext: "rb") } 6 | sequence(:email) { Faker::Internet.email } 7 | sequence(:hash) { Faker::Crypto.md5 } 8 | sequence(:name) { Faker::Name.name } 9 | sequence(:time) { Faker::Time.between(from: DateTime.now - 1, to: DateTime.now) } 10 | sequence(:number) { Faker::Number.number(digits: 2) } 11 | sequence(:change) do 12 | { 13 | orig_path: FactoryBot.generate(:file), 14 | final_signature: { 15 | time: FactoryBot.generate(:time), 16 | email: FactoryBot.generate(:email), 17 | name: FactoryBot.generate(:name) 18 | }, 19 | final_commit_id: FactoryBot.generate(:hash), 20 | lines_in_hunk: FactoryBot.generate(:number) 21 | } 22 | end 23 | 24 | sequence(:diff) do 25 | Array.new(rand(2..5)) { FactoryBot.generate(:change) } 26 | end 27 | 28 | initialize_with { new(**attributes) } 29 | 30 | factory :render, class: "GitFame::Render" do 31 | branch { "HEAD" } 32 | result 33 | end 34 | 35 | factory :filter, class: "GitFame::Filter" do 36 | trait :all do 37 | before { Faker::DateTime.backward(days: 365) } 38 | 39 | after { Faker::DateTime.forward(days: 365) } 40 | 41 | extensions { Array.new(2) { generate(:ext) } } 42 | end 43 | end 44 | 45 | factory :collector, class: "GitFame::Collector" do 46 | filter 47 | diff 48 | end 49 | 50 | factory :author, class: "GitFame::Author" do 51 | name 52 | email 53 | end 54 | 55 | factory :contribution, class: "GitFame::Contribution" do 56 | commits { Array.new(3) { generate(:hash) } } 57 | files { Array.new(3) { generate(:file) } } 58 | lines { generate(:number) } 59 | author 60 | end 61 | 62 | factory :result, class: "GitFame::Result" do 63 | contributions { build_list(:contribution, 3) } 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe GitFame::Filter do 4 | describe "#call" do 5 | let(:changes) { generate(:change) } 6 | 7 | context "when the filter contains all rules" do 8 | subject(:filter) do 9 | build(:filter, { 10 | include: Set["test*{.rb, .js, .ts}"], 11 | extensions: Set[".rb", ".js"], 12 | exclude: Set["*_spec.rb"], 13 | before:, 14 | after: 15 | }) 16 | end 17 | 18 | let(:now) { DateTime.now } 19 | let(:changes) { super().deep_merge(orig_path: file_path, final_signature: { time: }) } 20 | let(:file_path) { "test.rb" } 21 | let(:time) { Time.now } 22 | let(:before) { now + 1_000 } 23 | let(:after) { now - 1_000 } 24 | 25 | context "when change is valid" do 26 | it "invokes the block" do 27 | expect { |b| filter.call(changes, &b) }.to yield_control 28 | end 29 | end 30 | 31 | context "when the [exclude] filter fails" do 32 | let(:file_path) { "test_spec.rb" } 33 | let(:changes) { super().deep_merge(orig_path: file_path) } 34 | 35 | it "does not invoke the block" do 36 | expect { |b| filter.call(changes, &b) }.not_to yield_control 37 | end 38 | end 39 | end 40 | 41 | context "when the before filter is set to today" do 42 | subject(:filter) { build(:filter, before:) } 43 | 44 | let(:changes) { super().deep_merge(final_signature: { time: }) } 45 | let(:before) { DateTime.now } 46 | 47 | context "when the change is set BEFORE the [before] filter" do 48 | let(:time) { before - 1.day } 49 | 50 | it "invokes the block" do 51 | expect { |b| filter.call(changes, &b) }.to yield_control 52 | end 53 | end 54 | 55 | context "when the change is set AFTER the [before] filter" do 56 | let(:time) { before + 1.day } 57 | 58 | it "does not invoke the block" do 59 | expect { |b| filter.call(changes, &b) }.not_to yield_control 60 | end 61 | end 62 | end 63 | 64 | context "when the [exclude] filter is set to ignore [LI*ENCE]" do 65 | subject(:filter) { build(:filter, exclude:) } 66 | 67 | let(:changes) { super().deep_merge(orig_path: file_path) } 68 | let(:exclude) { Set["LI*ENCE"] } 69 | 70 | context "when the change does NOT match the glob pattern" do 71 | let(:file_path) { "README" } 72 | 73 | it "does invoke the block" do 74 | expect { |b| filter.call(changes, &b) }.to yield_control 75 | end 76 | end 77 | 78 | context "when the change does match the glob pattern" do 79 | let(:file_path) { "LICENCE" } 80 | 81 | it "does not invoke the block" do 82 | expect { |b| filter.call(changes, &b) }.not_to yield_control 83 | end 84 | end 85 | end 86 | 87 | context "when the [include] filter is set to include [*_spec.rb]" do 88 | subject(:filter) { build(:filter, include:) } 89 | 90 | let(:changes) { super().deep_merge(orig_path: file_path) } 91 | let(:include) { Set["*_spec.rb"] } 92 | 93 | context "when the change does NOT match the glob pattern" do 94 | let(:file_path) { "main.rb" } 95 | 96 | it "does not invoke the block" do 97 | expect { |b| filter.call(changes, &b) }.not_to yield_control 98 | end 99 | end 100 | 101 | context "when the change does match the glob pattern" do 102 | let(:file_path) { "main_spec.rb" } 103 | 104 | it "does invoke the block" do 105 | expect { |b| filter.call(changes, &b) }.to yield_control 106 | end 107 | end 108 | end 109 | 110 | context "when the [extensions] filter is set to ignore [.rb]" do 111 | subject(:filter) { build(:filter, extensions:) } 112 | 113 | let(:changes) { super().deep_merge(orig_path: file_path) } 114 | let(:extensions) { Set[".rb"] } 115 | 116 | context "when the change does NOT have an .rb extension" do 117 | let(:file_path) { "foo.js" } 118 | 119 | it "does not invoke the block" do 120 | expect { |b| filter.call(changes, &b) }.not_to yield_control 121 | end 122 | end 123 | 124 | context "when the change does have an .rb extension" do 125 | let(:file_path) { "foo.rb" } 126 | 127 | it "invokes the block" do 128 | expect { |b| filter.call(changes, &b) }.to yield_control 129 | end 130 | end 131 | end 132 | 133 | context "when the after filter is set to today" do 134 | subject(:filter) { build(:filter, after:) } 135 | 136 | let(:changes) { super().deep_merge(final_signature: { time: }) } 137 | let(:after) { DateTime.now } 138 | 139 | context "when the change is set BEFORE the after filter" do 140 | let(:time) { after - 1.day } 141 | 142 | it "does not invoke the block" do 143 | expect { |b| filter.call(changes, &b) }.not_to yield_control 144 | end 145 | end 146 | 147 | context "when the change is set AFTER the after filter" do 148 | let(:time) { after + 1.day } 149 | 150 | it "invokes the block" do 151 | expect { |b| filter.call(changes, &b) }.to yield_control 152 | end 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/render_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe GitFame::Render do 4 | let(:render) { build(:render) } 5 | 6 | it "renders to stdout" do 7 | expect { render.call }.to output(/name|email|lines|commits|files/).to_stdout 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/result_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe GitFame::Result do 4 | subject { build(:result) } 5 | 6 | its(:contributions) { is_expected.to be_present } 7 | 8 | describe "#authors" do 9 | its(:authors) { is_expected.to be_present } 10 | end 11 | 12 | describe "#commits" do 13 | its(:commits) { is_expected.to be_present } 14 | end 15 | 16 | describe "#lines" do 17 | its(:lines) { is_expected.to be > 0 } 18 | end 19 | 20 | describe "#files" do 21 | its(:files) { is_expected.to be_present } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | 5 | require "factory_bot" 6 | require "rspec/its" 7 | require "git_fame" 8 | require "faker" 9 | require "rspec" 10 | require "pry" 11 | 12 | require_relative "factories" 13 | 14 | RSpec.configure do |config| 15 | config.example_status_persistence_file_path = ".stats.rspec" 16 | config.include FactoryBot::Syntax::Methods 17 | config.filter_run_when_matching :focus 18 | end 19 | --------------------------------------------------------------------------------