├── .gitignore ├── .idea ├── .idea.gitnstats │ ├── .idea │ │ ├── contentModel.xml │ │ ├── modules.xml │ │ └── vcs.xml │ └── riderModule.iml └── build.sh ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE.txt ├── README.md ├── appveyor.yml ├── gitnstats.sln ├── publish.sh ├── src ├── gitnstats.core │ ├── Analysis.cs │ ├── CommitVisitor.cs │ ├── DiffCollector.cs │ ├── DiffListener.cs │ ├── Listener.cs │ ├── Tooling.cs │ ├── Visitor.cs │ └── gitnstats.core.csproj ├── gitnstats │ ├── CliView.cs │ ├── Controller.cs │ ├── FileSystem.cs │ ├── Options.cs │ ├── Program.cs │ ├── View.cs │ └── gitnstats.csproj ├── ubuntu14.dockerfile └── ubuntu16.dockerfile ├── test.sh └── tests ├── gitnstats.core.tests ├── Analysis │ ├── CountFileChangesSpec.cs │ └── DateFilter.cs ├── DiffCollectorTests.cs ├── DiffListenerTests.cs ├── Fakes.cs ├── Visitor │ └── Walk.cs └── gitnstats.core.tests.csproj └── gitnstats.test ├── ControllerTests.cs └── gitnstats.test.csproj /.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | bin/ 3 | obj/ 4 | **/coverage.json 5 | **/coverage.info 6 | 7 | # Rider 8 | ## User-specific stuff: 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/dictionaries 12 | *.user 13 | 14 | ## Sensitive or high-churn files: 15 | .idea/**/dataSources/ 16 | .idea/**/dataSources.ids 17 | .idea/**/dataSources.xml 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | 23 | # Mac 24 | .DS_Store 25 | 26 | # *nix 27 | .swp 28 | -------------------------------------------------------------------------------- /.idea/.idea.gitnstats/.idea/contentModel.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.idea.gitnstats/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.gitnstats/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.idea.gitnstats/riderModule.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /.idea/build.sh: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubberduck203/GitNStats/50433c7e9f7b5e803a9f5a04a0f8c45177d15800/.idea/build.sh -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "inputs": [ 7 | { 8 | "id": "pickArgs", 9 | "type": "pickString", 10 | "description": "Choose cli args", 11 | "options": [ 12 | "", 13 | "-d 2017-07-21", 14 | "--help" 15 | ] 16 | }, 17 | ], 18 | "configurations": [ 19 | 20 | { 21 | "name": "Debug (console)", 22 | "type": "coreclr", 23 | "request": "launch", 24 | "preLaunchTask": "build", 25 | // If you have changed target frameworks, make sure to update the program path. 26 | "program": "${workspaceRoot}/src/gitnstats/bin/Debug/net5.0/gitnstats.dll", 27 | "args": [ 28 | "${workspaceRoot}", 29 | "${input:pickArgs}" 30 | ], 31 | "cwd": "${workspaceRoot}/src/gitnstats", 32 | // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window 33 | "console": "internalConsole", 34 | "stopAtEntry": false, 35 | "internalConsoleOptions": "openOnSessionStart" 36 | }, 37 | { 38 | "name": ".NET Core Attach", 39 | "type": "coreclr", 40 | "request": "attach", 41 | "processId": "${command:pickProcess}" 42 | } 43 | ] 44 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "lcov.path": [ 3 | "tests/gitnstats.core.tests/bin/coverage.info", 4 | "tests/gitnstats.test/bin/coverage.info" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "command": "dotnet", 4 | "args": [], 5 | "tasks": [ 6 | { 7 | "label": "build", 8 | "type": "shell", 9 | "args": [ 10 | "build", 11 | "${workspaceRoot}/gitnstats.sln" 12 | ], 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | }, 17 | "problemMatcher": "$msCompile" 18 | }, 19 | { 20 | "label": "test", 21 | "group": { 22 | "kind": "test", 23 | "isDefault": true 24 | }, 25 | "args": [ 26 | "test", 27 | "${workspaceRoot}/gitnstats.sln" 28 | ], 29 | "problemMatcher": "$msCompile", 30 | }, 31 | { 32 | "label": "test w/ coverage", 33 | "group": "test", 34 | "type": "shell", 35 | "command": "./test.sh", 36 | "problemMatcher": "$msCompile", 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Christopher McClellan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitNStats 2 | 3 | Command line utility that reports back statistics about a git repository. 4 | 5 | There are many tools out there, but most of them are interested in commits per author and other such things. 6 | GitNStats is an experiment in analyzing how often particular files in a repositor change (and how big those changes were). 7 | The idea is to discover "hotspots" in a code base by determining which files have a lot of churn. 8 | 9 | [![Build status](https://ci.appveyor.com/api/projects/status/5ncbrrsob8t44bc5/branch/master?svg=true)](https://ci.appveyor.com/project/Rubberduck/gitnstats/branch/master) 10 | [![codecov](https://codecov.io/gh/rubberduck203/GitNStats/branch/master/graph/badge.svg)](https://codecov.io/gh/rubberduck203/GitNStats) 11 | 12 | ## Quickstart 13 | 14 | ```bash 15 | cd path/to/repo 16 | gitnstats 17 | ``` 18 | 19 | For usage instruction run: 20 | 21 | ```bash 22 | dotnet run --project src/gitnstats/gitnstats.csproj -- --help 23 | 24 | # or if you have a pre-packaged binary 25 | 26 | gitnstats --help 27 | ``` 28 | 29 | ### Philosophy 30 | 31 | GitNStats tries to follow [the Unix philosophy](https://en.wikipedia.org/wiki/Unix_philosophy). 32 | 33 | - Make each program do one thing well. 34 | - Expect the output of every program to become the input to another, as yet unknown, program. 35 | 36 | This means I've tried to stay light on the options and other bells and whistles. 37 | 38 | ### Options 39 | 40 | #### Date Filter 41 | 42 | The following command will return all a count of the number of times each file was committed on or after July 14th, 2017 on the develop branch. 43 | 44 | ```bash 45 | gitnstats path/to/repo -b develop -d 2017-07-14 46 | ``` 47 | 48 | #### QuietMode 49 | 50 | By default, GitNStats will print repository and branch information along with the history analysis. 51 | However, this can be awkward when piping output into another program, like `sort` for further processing. 52 | This extra info and column headers can be suppressed with the `-q` switch. 53 | 54 | ```bash 55 | gitnstats -q | sort 56 | ``` 57 | 58 | ### Common Operations 59 | 60 | I've tried to provide instructions for common use cases below, but as tools differ from OS to OS, 61 | I've not tested that they work everywhere. 62 | If something doesn't work on your OS of choice, please open a pull request. 63 | I don't have a Windows machine at the moment, so equivalent batch and powershell command would be appreciated. 64 | 65 | #### Saving to file 66 | 67 | Should work on basically every OS I know of, including Windows. 68 | 69 | ```bash 70 | gitnstats > filename.txt 71 | ``` 72 | 73 | #### Sorting 74 | 75 | Output is sorted ascending by default. 76 | 77 | Descending: 78 | 79 | ```bash 80 | gitnstats -q | sort -n 81 | ``` 82 | 83 | Ascending without headers & branch info: 84 | 85 | ```bash 86 | gitnstats -q | sort -nr 87 | ``` 88 | 89 | #### Display Top N Files 90 | 91 | Display the 10 most changed files: 92 | 93 | ```bash 94 | gitnstats -q | sort -nr | head -n10 95 | ``` 96 | 97 | Display the 20 least changed files: 98 | 99 | ```bash 100 | gitnstats -q | sort -nr | tail -n20 101 | ``` 102 | 103 | #### Display Only Files with More than N Commits 104 | 105 | You can use awk to filter results. 106 | The following command will print only records where the first column (the number of commits) 107 | is greater than or equal to 15. 108 | 109 | ```bash 110 | gitnstats -q | awk '$1 >= 15' 111 | ``` 112 | 113 | #### Filtering 114 | 115 | Display files with a specific file extension: 116 | 117 | ```bash 118 | gitnstats | grep \\.cs$ 119 | ``` 120 | 121 | `$` indicates end of line. 122 | The `.` must be escaped with a `\`, but since the shell uses the `\` character as a line continuation, we must escape it as well. 123 | 124 | Filter on a directory: 125 | 126 | ```bash 127 | gitnstats | grep tests/ 128 | ``` 129 | 130 | ## Installation 131 | 132 | Obtain the [latest release](https://github.com/rubberduck203/GitNStats/releases/latest). 133 | 134 | Unzip the distribution into your target directory. 135 | The program can be run from this location, added to your PATH, 136 | or symbolic linked to a location that is already on your PATH. 137 | 138 | Symoblic linking to a location already on the PATH (like `/usr/local/bin/`) is recommended as it keeps your path clean. 139 | 140 | ```bash 141 | # Download release (replace version and runtime accordingly) 142 | cd ~/Downloads 143 | wget https://github.com/rubberduck203/GitNStats/releases/download/2.3.1/osx-x64.zip 144 | 145 | # Create directory to keep package 146 | mkdir -p ~/bin/gitnstats 147 | 148 | # unzip 149 | unzip osx-x64.zip -d ~/bin/gitnstats 150 | 151 | # Create symlink 152 | ln -s /Users/rubberduck/bin/gitnstats/gitnstats /usr/local/bin/gitnstats 153 | ``` 154 | 155 | Alternatively, you may want to keep the executable in the `/usr/local/share/` directory. 156 | 157 | ### .Net Dependencies 158 | 159 | This project uses "self-contained" .Net Core deployment. 160 | "Self contained" is quoted because although the *.zip archive includes the .Net runtime, 161 | the .Net runtime has dependencies of it's own that need to be available. 162 | Please see the [list of .Net Core runtime dependencies.][dotnet-deps] and make sure they're installed first. 163 | 164 | [dotnet-deps]: https://github.com/dotnet/core/blob/master/Documentation/prereqs.md 165 | 166 | ## Build 167 | 168 | ```bash 169 | dotnet restore 170 | dotnet build 171 | ``` 172 | 173 | ## Tests 174 | 175 | If you're using VS Code, there's a test task. 176 | Cmd + P -> `> Tasks: Run Test Task` 177 | 178 | Otherwise... 179 | 180 | ```bash 181 | dotnet test 182 | ``` 183 | 184 | ### Code Coverage 185 | 186 | If you use the `./test.sh` or `test w/ coverage` Task Runner in VSCode, you can generate lcov reports and use the [lcov extension](https://marketplace.visualstudio.com/items?itemName=alexdima.vscode-lcov) to view the code coverage. 187 | 188 | [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) will work too, but you need to tell it to look for the right file name. 189 | 190 | ## Publish 191 | 192 | To maintain compatibility with our build server, we use 7zip to create the archive. 193 | 194 | ```bash 195 | brew install p7zip 196 | ``` 197 | 198 | The publish script will package and zip a stand alone executable for each runtime specified in the *.csproj. 199 | 200 | ```bash 201 | ./publish.sh 202 | ``` 203 | 204 | ### Integration Tests 205 | 206 | The integration tests have two purposes. 207 | 208 | 1. Verify the self-contained publish works properly for an OS. 209 | 2. Document the .Net runtime dependencies for that OS. 210 | 211 | If the tests is successful, you'll see output from the application and a get successful return code of 0. 212 | All of the dockerfiles assume you have already run the `publish.sh` script. 213 | 214 | #### Ubuntu 14.04 215 | 216 | ```bash 217 | docker build -f src/ubuntu14.dockerfile -t rubberduck/gitnstats:ubuntu14 src 218 | docker run rubberduck/gitnstats:ubuntu14 219 | ``` 220 | 221 | #### Ubuntu 16.04 222 | 223 | ```bash 224 | docker build -f src/ubuntu16.dockerfile -t rubberduck/gitnstats:ubuntu16 src 225 | docker run rubberduck/gitnstats:ubuntu16 226 | ``` -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 2.3.1.{build} 2 | pull_requests: 3 | do_not_increment_build_number: true 4 | image: Visual Studio 2019 Preview 5 | configuration: Release 6 | environment: 7 | CODECOV_TOKEN: 8 | secure: XqcH4XNIcPF1EAuxfTCmhxV4f4GlLbcYMwY9awynisZu860oD8kb79jyrNzhspU6 9 | build_script: 10 | - cmd: >- 11 | dotnet build -c release 12 | 13 | test_script: 14 | - ps: | 15 | $env:MSYS_NO_PATHCONV=1 16 | bash test.sh 17 | 18 | after_test: 19 | - ps: | 20 | $env:PATH = 'C:\msys64\usr\bin;' + $env:PATH 21 | Invoke-WebRequest -Uri 'https://codecov.io/bash' -OutFile codecov.sh 22 | bash codecov.sh -f "tests/gitnstats.test/bin/coverage.opencover.xml" -f "tests/gitnstats.core.tests/bin/coverage.opencover.xml" -t $env:CODECOV_TOKEN 23 | 24 | bash publish.sh 25 | 26 | artifacts: 27 | - path: src/gitnstats/bin/release/net5.0/*.zip 28 | 29 | deploy: 30 | provider: GitHub 31 | auth_token: 32 | secure: qCdYmo3GpVijXI13X6CBM7J3JEArOF2QgWOOQ4pve74wnW2+yAt6mf8uIyDg7DMg 33 | tag: $(appveyor_repo_tag_name) 34 | draft: true 35 | prerelease: false 36 | on: 37 | branch: master 38 | appveyor_repo_tag: true # deploy on tag push only -------------------------------------------------------------------------------- /gitnstats.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A756E523-B79C-40D0-994D-CEABD8E67613}" 7 | ProjectSection(SolutionItems) = preProject 8 | src\ubuntu16.dockerfile = src\ubuntu16.dockerfile 9 | src\ubuntu14.dockerfile = src\ubuntu14.dockerfile 10 | EndProjectSection 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "gitnstats", "src\gitnstats\gitnstats.csproj", "{F9B73133-5937-418C-926A-26739E9FE602}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{03E9CAAB-BDB2-4F4F-89E3-3D4AFE1B8629}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "gitnstats.core", "src\gitnstats.core\gitnstats.core.csproj", "{88666E24-E7CA-4996-94A5-FB78BDBA985F}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "gitnstats.core.tests", "tests\gitnstats.core.tests\gitnstats.core.tests.csproj", "{FF96868C-A197-406D-A09A-317356F7C5AF}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "gitnstats.test", "tests\gitnstats.test\gitnstats.test.csproj", "{657F371E-45C7-4745-BCCC-40574283B6C4}" 21 | EndProject 22 | Global 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Debug|x64 = Debug|x64 26 | Debug|x86 = Debug|x86 27 | Release|Any CPU = Release|Any CPU 28 | Release|x64 = Release|x64 29 | Release|x86 = Release|x86 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 35 | {F9B73133-5937-418C-926A-26739E9FE602}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {F9B73133-5937-418C-926A-26739E9FE602}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {F9B73133-5937-418C-926A-26739E9FE602}.Debug|x64.ActiveCfg = Debug|x64 38 | {F9B73133-5937-418C-926A-26739E9FE602}.Debug|x64.Build.0 = Debug|x64 39 | {F9B73133-5937-418C-926A-26739E9FE602}.Debug|x86.ActiveCfg = Debug|x86 40 | {F9B73133-5937-418C-926A-26739E9FE602}.Debug|x86.Build.0 = Debug|x86 41 | {F9B73133-5937-418C-926A-26739E9FE602}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {F9B73133-5937-418C-926A-26739E9FE602}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {F9B73133-5937-418C-926A-26739E9FE602}.Release|x64.ActiveCfg = Release|x64 44 | {F9B73133-5937-418C-926A-26739E9FE602}.Release|x64.Build.0 = Release|x64 45 | {F9B73133-5937-418C-926A-26739E9FE602}.Release|x86.ActiveCfg = Release|x86 46 | {F9B73133-5937-418C-926A-26739E9FE602}.Release|x86.Build.0 = Release|x86 47 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Debug|x64.ActiveCfg = Debug|x64 50 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Debug|x64.Build.0 = Debug|x64 51 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Debug|x86.ActiveCfg = Debug|x86 52 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Debug|x86.Build.0 = Debug|x86 53 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Release|x64.ActiveCfg = Release|x64 56 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Release|x64.Build.0 = Release|x64 57 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Release|x86.ActiveCfg = Release|x86 58 | {88666E24-E7CA-4996-94A5-FB78BDBA985F}.Release|x86.Build.0 = Release|x86 59 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Debug|x64.ActiveCfg = Debug|x64 62 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Debug|x64.Build.0 = Debug|x64 63 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Debug|x86.ActiveCfg = Debug|x86 64 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Debug|x86.Build.0 = Debug|x86 65 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Release|x64.ActiveCfg = Release|x64 68 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Release|x64.Build.0 = Release|x64 69 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Release|x86.ActiveCfg = Release|x86 70 | {FF96868C-A197-406D-A09A-317356F7C5AF}.Release|x86.Build.0 = Release|x86 71 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 72 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 73 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Debug|x64.ActiveCfg = Debug|x64 74 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Debug|x64.Build.0 = Debug|x64 75 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Debug|x86.ActiveCfg = Debug|x86 76 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Debug|x86.Build.0 = Debug|x86 77 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 78 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Release|Any CPU.Build.0 = Release|Any CPU 79 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Release|x64.ActiveCfg = Release|x64 80 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Release|x64.Build.0 = Release|x64 81 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Release|x86.ActiveCfg = Release|x86 82 | {657F371E-45C7-4745-BCCC-40574283B6C4}.Release|x86.Build.0 = Release|x86 83 | EndGlobalSection 84 | GlobalSection(NestedProjects) = preSolution 85 | {F9B73133-5937-418C-926A-26739E9FE602} = {A756E523-B79C-40D0-994D-CEABD8E67613} 86 | {88666E24-E7CA-4996-94A5-FB78BDBA985F} = {A756E523-B79C-40D0-994D-CEABD8E67613} 87 | {FF96868C-A197-406D-A09A-317356F7C5AF} = {03E9CAAB-BDB2-4F4F-89E3-3D4AFE1B8629} 88 | {657F371E-45C7-4745-BCCC-40574283B6C4} = {03E9CAAB-BDB2-4F4F-89E3-3D4AFE1B8629} 89 | EndGlobalSection 90 | EndGlobal 91 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | usage() 5 | { 6 | echo "usage: publish [--skip-archive] | [-h]]" 7 | } 8 | 9 | archive=true 10 | while [ "$1" != "" ]; do 11 | case $1 in 12 | -s | --skip-archive ) archive=false 13 | ;; 14 | -h | --help ) usage 15 | exit 16 | ;; 17 | * ) usage 18 | exit 1 19 | esac 20 | shift 21 | done 22 | 23 | framework=net5.0 24 | project_root=src/gitnstats 25 | project_path=${project_root}/gitnstats.csproj 26 | bin=${project_root}/bin/Release 27 | 28 | echo "Cleaning ${bin}" 29 | rm -rf ${bin}/** 30 | 31 | # build the list of runtimes by parsing the *.csproj for runtime identifiers 32 | IFS=';' read -ra runtimes <<< "$(grep '' ${project_path} | sed -e 's,.*\([^<]*\).*,\1,g')" 33 | 34 | for runtime in ${runtimes[@]}; do 35 | echo "Restoring ${runtime}" 36 | dotnet restore -r ${runtime} ${project_path} 37 | 38 | echo "Packaging ${runtime}" 39 | dotnet publish -c release -r ${runtime} -p:PublishSingleFile=true ${project_path} 40 | 41 | build=${bin}/${framework}/${runtime} 42 | publish=${build}/publish 43 | 44 | if [[ ${runtime} != win* ]]; then 45 | exe=${publish}/gitnstats 46 | echo "chmod +x ${exe}" 47 | chmod +x ${exe} 48 | fi 49 | 50 | if $archive; then 51 | # subshell so we can specify the archive's root directory 52 | ( 53 | cd ${publish} 54 | archive=../../${runtime}.zip 55 | echo "Compressing to ${archive}" 56 | 7z a ${archive} ./ 57 | ) 58 | else 59 | echo "Skipping archival" 60 | fi 61 | done 62 | exit 0 -------------------------------------------------------------------------------- /src/gitnstats.core/Analysis.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using LibGit2Sharp; 5 | 6 | namespace GitNStats.Core 7 | { 8 | public record PathCount(string Path, int Count); 9 | public record CommitDiff(Commit Commit, TreeEntryChanges Diff); 10 | 11 | public delegate bool DiffFilterPredicate(CommitDiff diff); 12 | 13 | public static class Analysis 14 | { 15 | public static IEnumerable CountFileChanges(IEnumerable diffs) => 16 | diffs.Aggregate>( 17 | new Dictionary(), //filename, count 18 | (acc, x) => 19 | { 20 | /* OldPath == NewPath when file was created or removed, 21 | so this it's okay to just always use OldPath */ 22 | acc[x.Diff.Path] = acc.GetOrDefault(x.Diff.OldPath, 0) + 1; 23 | 24 | if (x.Diff.Status == ChangeKind.Renamed) 25 | { 26 | acc.Remove(x.Diff.OldPath); 27 | } 28 | 29 | return acc; 30 | } 31 | ) 32 | .Select(x => new PathCount(x.Key, x.Value)) 33 | .OrderByDescending(s => s.Count); 34 | 35 | /// 36 | /// Predicate for filtering by date 37 | /// 38 | /// Local DateTime 39 | /// True if Commit was on or after , otherwise false. 40 | public static DiffFilterPredicate OnOrAfter(DateTime onOrAfter) => 41 | change => change.Commit.Author.When.ToUniversalTime() >= onOrAfter.ToUniversalTime(); 42 | } 43 | 44 | static class DictionaryExtensions 45 | { 46 | public static V GetOrDefault(this Dictionary dictionary, K key, V defaultValue) 47 | where K : notnull => 48 | dictionary.TryGetValue(key, out V value) ? value : defaultValue; 49 | } 50 | } -------------------------------------------------------------------------------- /src/gitnstats.core/CommitVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | using LibGit2Sharp; 6 | using static GitNStats.Core.Tooling; 7 | 8 | namespace GitNStats.Core 9 | { 10 | /// 11 | /// Walks the commit graph back to the beginning of time. 12 | /// Guaranteed to only visit a commit once. 13 | /// 14 | public class CommitVisitor : Visitor 15 | { 16 | /// 17 | /// Walk the graph from this commit back. 18 | /// 19 | /// The commit to start at. 20 | public override void Walk(Commit commit) 21 | { 22 | Walk(commit, new HashSet()); 23 | //WithStopWatch(() => Walk(commit, new HashSet()), "Total Time Walking Graph: {0}"); 24 | } 25 | 26 | private Object padlock = new(); 27 | private void Walk(Commit commit, ISet visitedCommits) 28 | { 29 | // It's not safe to concurrently write to the Set. 30 | // If two threads hit this at the same time we could visit the same commit twice. 31 | lock(padlock) 32 | { 33 | // If we weren't successful in adding the commit, we've already been here. 34 | if(!visitedCommits.Add(commit.Sha)) 35 | return; 36 | } 37 | 38 | OnVisited(this, commit); 39 | 40 | Parallel.ForEach(commit.Parents, parent => 41 | { 42 | Walk(parent, visitedCommits); 43 | }); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/gitnstats.core/DiffCollector.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using LibGit2Sharp; 4 | 5 | namespace GitNStats.Core 6 | { 7 | public interface AsyncVisitor 8 | { 9 | Task> Walk(Commit commit); 10 | } 11 | 12 | public class DiffCollector : AsyncVisitor 13 | { 14 | private readonly Visitor _visitor; 15 | private readonly IDiffListener _listener; 16 | 17 | public DiffCollector(Visitor visitor, IDiffListener listener) 18 | { 19 | _visitor = visitor; 20 | _listener = listener; 21 | } 22 | 23 | public Task> Walk(Commit commit) 24 | { 25 | return Task.Run(() => 26 | { 27 | _visitor.Visited += _listener.OnCommitVisited; 28 | try 29 | { 30 | _visitor.Walk(commit); 31 | return _listener.Diffs; 32 | } 33 | finally 34 | { 35 | _visitor.Visited -= _listener.OnCommitVisited; 36 | } 37 | }); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/gitnstats.core/DiffListener.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Concurrent; 4 | using System.Threading.Tasks; 5 | using LibGit2Sharp; 6 | 7 | namespace GitNStats.Core 8 | { 9 | public interface IDiffListener : Listener 10 | { 11 | /// 12 | /// The diff cache. 13 | /// Clients should wait until the is done walking the graph before accessing. 14 | /// 15 | IEnumerable Diffs { get; } 16 | } 17 | 18 | /// 19 | /// When a Commit is visited, compares that commit to it's parents 20 | /// and stores the resulting TreeEntryChanges in the property. 21 | /// 22 | public class DiffListener : IDiffListener 23 | { 24 | private readonly IRepository _repository; 25 | private readonly ConcurrentBag _diffs = new(); 26 | 27 | /// 28 | /// The diff cache. 29 | /// Clients should wait until the is done walking the graph before accessing. 30 | /// 31 | public IEnumerable Diffs => _diffs; 32 | 33 | public DiffListener(IRepository repository) 34 | { 35 | _repository = repository; 36 | } 37 | 38 | /// 39 | /// Compares the commit to it's parents and caches the diffs in . 40 | /// 41 | /// The that raised the event. 42 | /// The currently being visited. 43 | public void OnCommitVisited(Visitor visitor, Commit visited) 44 | { 45 | foreach(var parent in visited.Parents) 46 | { 47 | var diff = _repository.Diff.Compare(parent.Tree, visited.Tree); 48 | 49 | foreach (var changed in diff) 50 | { 51 | _diffs.Add(new CommitDiff(visited, changed)); 52 | } 53 | } 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /src/gitnstats.core/Listener.cs: -------------------------------------------------------------------------------- 1 | using LibGit2Sharp; 2 | 3 | namespace GitNStats.Core 4 | { 5 | /// 6 | /// Listens for a to raise the event. 7 | /// 8 | public interface Listener 9 | { 10 | /// 11 | /// To be executed whenever a visitor vistis a commit. 12 | /// 13 | /// The that raised the event. 14 | /// The currently being visited. 15 | void OnCommitVisited(Visitor visitor, Commit visited); 16 | } 17 | } -------------------------------------------------------------------------------- /src/gitnstats.core/Tooling.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | namespace GitNStats.Core 5 | { 6 | public static class Tooling 7 | { 8 | public static void WithStopWatch(Action action, String formatString) 9 | { 10 | var stopwatch = new Stopwatch(); 11 | stopwatch.Start(); 12 | 13 | action(); 14 | 15 | stopwatch.Stop(); 16 | Console.WriteLine(formatString, stopwatch.ElapsedMilliseconds); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/gitnstats.core/Visitor.cs: -------------------------------------------------------------------------------- 1 | using LibGit2Sharp; 2 | 3 | namespace GitNStats.Core 4 | { 5 | public abstract class Visitor 6 | { 7 | public delegate void VisitedHandler(Visitor visitor, Commit commit); 8 | public virtual event VisitedHandler? Visited; 9 | 10 | public abstract void Walk(Commit commit); 11 | 12 | protected virtual void OnVisited(Visitor visitor, Commit commit) 13 | { 14 | Visited?.Invoke(visitor, commit); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/gitnstats.core/gitnstats.core.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | enable 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/gitnstats/CliView.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using GitNStats.Core; 4 | using LibGit2Sharp; 5 | 6 | namespace GitNStats 7 | { 8 | public class CliView : View 9 | { 10 | public bool QuietMode { get; set; } 11 | 12 | public void DisplayRepositoryInfo(string repositoryPath, Branch branch) 13 | { 14 | if (QuietMode) return; 15 | 16 | Console.WriteLine($"Repository: {repositoryPath}"); 17 | Console.WriteLine($"Branch: {branch.FriendlyName}"); 18 | Console.WriteLine(); 19 | } 20 | 21 | public void DisplayPathCounts(IEnumerable pathCounts) 22 | { 23 | if (!QuietMode) 24 | { 25 | Console.WriteLine("Commits\tPath"); 26 | } 27 | 28 | foreach (var summary in pathCounts) 29 | { 30 | Console.WriteLine($"{summary.Count}\t{summary.Path}"); 31 | } 32 | } 33 | 34 | public void DisplayError(string message) 35 | { 36 | var currentColor = Console.ForegroundColor; 37 | Console.ForegroundColor = ConsoleColor.Red; 38 | try 39 | { 40 | Console.Error.WriteLine(message); 41 | } 42 | finally 43 | { 44 | Console.ForegroundColor = currentColor; 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/gitnstats/Controller.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using GitNStats.Core; 5 | using LibGit2Sharp; 6 | 7 | namespace GitNStats 8 | { 9 | public enum Result 10 | { 11 | Success = 0, 12 | Failure = 1 13 | } 14 | 15 | public class Controller 16 | { 17 | private readonly View _view; 18 | private readonly FileSystem _fileSystem; 19 | 20 | private readonly Func RepositoryFactory; 21 | private readonly Func AsyncVisitorFactory; 22 | 23 | public Controller(View view, FileSystem fileSystem, Func repositoryFactory, Func asyncVisitorFactory) 24 | { 25 | _view = view; 26 | _fileSystem = fileSystem; 27 | RepositoryFactory = repositoryFactory; 28 | AsyncVisitorFactory = asyncVisitorFactory; 29 | } 30 | 31 | public Task RunAnalysis(Options options) 32 | { 33 | _view.QuietMode = options.QuietMode; 34 | 35 | var repoPath = RepositoryPath(options); 36 | var filter = Filter(options.DateFilter); 37 | 38 | return RunAnalysis(repoPath, options.BranchName, filter); 39 | } 40 | 41 | private async Task RunAnalysis(string repositoryPath, string? branchName, DiffFilterPredicate diffFilter) 42 | { 43 | try 44 | { 45 | using (var repo = RepositoryFactory(repositoryPath)) 46 | { 47 | var branch = Branch(branchName, repo); 48 | if (branch == null) 49 | { 50 | _view.DisplayError($"Invalid branch: {branchName}"); 51 | return Result.Failure; 52 | } 53 | 54 | _view.DisplayRepositoryInfo(repositoryPath, branch); 55 | 56 | var diffs = await AsyncVisitorFactory(repo).Walk(branch.Tip); 57 | var filteredDiffs = diffs.Where(diffFilter.Invoke); 58 | 59 | _view.DisplayPathCounts(Analysis.CountFileChanges(filteredDiffs)); 60 | return Result.Success; 61 | } 62 | } 63 | catch (RepositoryNotFoundException) 64 | { 65 | _view.DisplayError($"{repositoryPath} is not a git repository."); 66 | return Result.Failure; 67 | } 68 | } 69 | 70 | private string RepositoryPath(Options options) 71 | { 72 | return String.IsNullOrWhiteSpace(options.RepositoryPath) 73 | ? _fileSystem.CurrentDirectory() 74 | : options.RepositoryPath; 75 | } 76 | 77 | private static DiffFilterPredicate Filter(DateTime? dateFilter) 78 | { 79 | if (dateFilter == null) 80 | { 81 | return NoFilter; 82 | } 83 | return OnOrAfter; 84 | 85 | static bool NoFilter(CommitDiff diffs) => true; 86 | bool OnOrAfter(CommitDiff diffs) 87 | { 88 | // Datetime may come in in as "unspecified", we need to be sure it's specified 89 | // to get accurate comparisons to a commit's DateTimeOffset 90 | return Analysis.OnOrAfter(DateTime.SpecifyKind(dateFilter.Value, DateTimeKind.Local))(diffs); 91 | } 92 | } 93 | 94 | private static Branch Branch(string? branchName, IRepository repo) 95 | { 96 | // returns null if branch name is specified but doesn't exist 97 | return (branchName == null) ? repo.Head : repo.Branches[branchName]; 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /src/gitnstats/FileSystem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace GitNStats 5 | { 6 | /// 7 | /// Provides a mockable file system abstraction on top of System.IO 8 | /// 9 | public class FileSystem 10 | { 11 | /// 12 | /// Gets the current working directory of the application. 13 | /// 14 | public virtual string CurrentDirectory() 15 | { 16 | return Directory.GetCurrentDirectory(); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/gitnstats/Options.cs: -------------------------------------------------------------------------------- 1 | // re-enable when https://github.com/commandlineparser/commandline/pull/715 is merged & released 2 | #nullable disable 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using CommandLine; 7 | using CommandLine.Text; 8 | 9 | namespace GitNStats 10 | { 11 | public class Options 12 | { 13 | private const string RepoPathHelpText = "Path to the git repository to be analyzed. Defaults to the current working directory."; 14 | 15 | [Value(1, HelpText = RepoPathHelpText, MetaName = "FilePath")] 16 | public string RepositoryPath { get; set; } 17 | 18 | [Option('b', "branch", HelpText = "Defaults to the currently active branch.")] 19 | public string BranchName { get; set; } 20 | 21 | [Option('d', "date-filter", HelpText = "Get commits on or after this date. Defaults to no filter.")] 22 | public DateTime? DateFilter { get; set; } 23 | 24 | [Option('q', "quiet", HelpText = "When in quiet mode, repository and branch info is not part of the output. This simplifies further analysis via piping into other command line tools.")] 25 | public bool QuietMode { get; set; } 26 | 27 | [Usage] 28 | public static IEnumerable Examples 29 | { 30 | get 31 | { 32 | yield return new Example("Run on current directory", new Options()); 33 | yield return new Example("Run on specific repository", new Options() { RepositoryPath = "/Users/rubberduck/src/repository"}); 34 | yield return new Example("Run on specific branch", new Options(){ BranchName = "develop" }); 35 | yield return new Example("Specify date filter", new Options(){DateFilter = DateTime.Today}); 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/gitnstats/Program.cs: -------------------------------------------------------------------------------- 1 | using LibGit2Sharp; 2 | using CommandLine; 3 | using GitNStats.Core; 4 | 5 | namespace GitNStats 6 | { 7 | static class Program 8 | { 9 | public static int Main(string[] args) 10 | { 11 | return Parser.Default.ParseArguments(args) 12 | .MapResult(options => 13 | { 14 | IRepository RepositoryFactory(string repoPath) => new Repository(repoPath); 15 | AsyncVisitor AsyncVisitorFactory(IRepository repo) => new DiffCollector(new CommitVisitor(), new DiffListener(repo)); 16 | 17 | var controller = new Controller(new CliView(), new FileSystem(), RepositoryFactory, AsyncVisitorFactory); 18 | 19 | return (int)controller.RunAnalysis(options).GetAwaiter().GetResult(); 20 | }, _ => (int)Result.Failure); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/gitnstats/View.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using GitNStats.Core; 3 | using LibGit2Sharp; 4 | 5 | namespace GitNStats 6 | { 7 | public interface View 8 | { 9 | bool QuietMode { get; set; } 10 | void DisplayRepositoryInfo(string repositoryPath, Branch branch); 11 | void DisplayPathCounts(IEnumerable pathCounts); 12 | void DisplayError(string message); 13 | } 14 | } -------------------------------------------------------------------------------- /src/gitnstats/gitnstats.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Exe 4 | net5.0 5 | 2.3.1 6 | true 7 | osx-x64;linux-x64;win10-x64 8 | enable 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Christopher J. McClellan 23 | 24 | -------------------------------------------------------------------------------- /src/ubuntu14.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:14.04 2 | RUN apt-get update \ 3 | && apt-get install -y --no-install-recommends \ 4 | ca-certificates \ 5 | \ 6 | # .NET Core dependencies 7 | libc6 \ 8 | libcurl3 \ 9 | libgcc1 \ 10 | libgssapi-krb5-2 \ 11 | libicu52 \ 12 | liblttng-ust0 \ 13 | libssl1.0.0 \ 14 | libstdc++6 \ 15 | libunwind8 \ 16 | libuuid1 \ 17 | zlib1g \ 18 | # Install git for testing purposes 19 | git \ 20 | # Clean up 21 | && rm -rf /var/lib/apt/lists/* 22 | WORKDIR /root/ 23 | RUN git clone https://github.com/rubberduck203/GitNStats 24 | COPY gitnstats/bin/release/net5.0/linux-x64/publish/ /root/bin/ 25 | CMD bin/gitnstats GitNStats/ -------------------------------------------------------------------------------- /src/ubuntu16.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | RUN apt-get update \ 3 | && apt-get install -y --no-install-recommends \ 4 | ca-certificates \ 5 | \ 6 | # .NET Core dependencies 7 | libc6 \ 8 | libcurl3 \ 9 | libgcc1 \ 10 | libgssapi-krb5-2 \ 11 | libicu55 \ 12 | liblttng-ust0 \ 13 | libssl1.0.0 \ 14 | libstdc++6 \ 15 | libunwind8 \ 16 | libuuid1 \ 17 | zlib1g \ 18 | # Install git for testing purposes 19 | git \ 20 | # Clean up 21 | && rm -rf /var/lib/apt/lists/* 22 | WORKDIR /root/ 23 | RUN git clone https://github.com/rubberduck203/GitNStats 24 | COPY gitnstats/bin/release/net5.0/linux-x64/publish/ /root/bin/ 25 | CMD bin/gitnstats GitNStats/ 26 | 27 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | dotnet test tests/gitnstats.test/gitnstats.test.csproj \ 5 | /p:CollectCoverage=true \ 6 | /p:Exclude=\"[gitnstats.core]*,[gitnstats.core.tests]*,[gitnstats]GitNStats.CliView,[gitnstats]GitNStats.Program,[gitnstats]GitNStats.Options,[gitnstats]GitNStats.FileSystem\" \ 7 | /p:CoverletOutput=./bin/ \ 8 | /p:CoverletOutputFormat=\"json,opencover,lcov\" 9 | 10 | dotnet test tests/gitnstats.core.tests/gitnstats.core.tests.csproj \ 11 | /p:CollectCoverage=true \ 12 | /p:CoverletOutput=./bin/ \ 13 | /p:CoverletOutputFormat=\"json,opencover,lcov\" -------------------------------------------------------------------------------- /tests/gitnstats.core.tests/Analysis/CountFileChangesSpec.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | using GitNStats.Core.Tests; 6 | using LibGit2Sharp; 7 | using Xunit; 8 | using Moq; 9 | 10 | using static GitNStats.Core.Analysis; 11 | using static GitNStats.Core.Tests.Fakes; 12 | using GitNStats.Core; 13 | 14 | namespace GitNStats.Tests.Analysis 15 | { 16 | public class CountFileChangesSpec 17 | { 18 | [Fact] 19 | public void OneEntryIsCounted() 20 | { 21 | var diffs = new List() 22 | { 23 | new(Fakes.Commit().Object, Fakes.TreeEntryChanges("path/to/file").Object), 24 | }; 25 | 26 | Assert.Equal(1, CountFileChanges(diffs).Single().Count); 27 | } 28 | 29 | [Fact] 30 | public void TwoEntriesForSameFileAreCounted() 31 | { 32 | var diffs = new List() 33 | { 34 | new(Fakes.Commit().Object, Fakes.TreeEntryChanges("path/to/file").Object), 35 | new(Fakes.Commit().Object, Fakes.TreeEntryChanges("path/to/file").Object), 36 | }; 37 | 38 | Assert.Equal(2, CountFileChanges(diffs).Single().Count); 39 | } 40 | 41 | [Fact] 42 | public void TwoEntriesForTwoDifferentFilesAreCountedSeparately() 43 | { 44 | var diffs = new List() 45 | { 46 | new(Fakes.Commit().Object, Fakes.TreeEntryChanges("path/to/fileA").Object), 47 | new(Fakes.Commit().Object, Fakes.TreeEntryChanges("path/to/fileB").Object), 48 | new(Fakes.Commit().Object, Fakes.TreeEntryChanges("path/to/fileB").Object), 49 | }; 50 | 51 | Assert.Equal(1, CountFileChanges(diffs).Single(d => d.Path == "path/to/fileA").Count); 52 | Assert.Equal(2, CountFileChanges(diffs).Single(d => d.Path == "path/to/fileB").Count); 53 | } 54 | 55 | [Fact] 56 | public void RenamedFileHasHistory() 57 | { 58 | var fileA = Fakes.TreeEntryChanges("fileA"); 59 | var fileB = Fakes.TreeEntryChanges("fileB"); 60 | fileB.SetupGet(d => d.Status).Returns(ChangeKind.Renamed); 61 | fileB.SetupGet(d => d.OldPath).Returns(fileA.Object.Path); 62 | 63 | var diffs = new List() 64 | { 65 | new(Fakes.Commit().Object, fileA.Object), 66 | new(Fakes.Commit().Object, fileB.Object) 67 | }; 68 | 69 | var pathCounts = CountFileChanges(diffs); 70 | Assert.Equal("fileB", pathCounts.Single().Path); 71 | Assert.Equal(2, pathCounts.Single().Count); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /tests/gitnstats.core.tests/Analysis/DateFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using GitNStats.Core.Tests; 3 | using Xunit; 4 | 5 | using static GitNStats.Core.Analysis; 6 | using static GitNStats.Core.Tests.Fakes; 7 | 8 | namespace GitNStats.Tests.Analysis 9 | { 10 | public class DateFilter 11 | { 12 | static TimeSpan AdtOffset = new TimeSpan(-3,0,0); 13 | static TimeSpan EstOffset = new TimeSpan(-4,0,0); 14 | static TimeSpan CstOffset = new TimeSpan(-6,0,0); 15 | 16 | static DateTime TestTime = new DateTime(2017,6,21,13,30,0); 17 | 18 | [Fact] 19 | public void LaterThanFilter_ReturnsTrue() 20 | { 21 | var commitTime = new DateTimeOffset(TestTime, EstOffset) + TimeSpan.FromDays(1); 22 | var commit = Commit().WithAuthor(commitTime); 23 | var predicate = OnOrAfter(TestTime.Date); 24 | Assert.True(predicate(Diff(commit))); 25 | } 26 | 27 | [Fact] 28 | public void PriorToFilter_ReturnsFalse() 29 | { 30 | var commitTime = new DateTimeOffset(TestTime, EstOffset) - TimeSpan.FromDays(1); 31 | var commit = Commit().WithAuthor(commitTime); 32 | var predicate = OnOrAfter(TestTime.Date); 33 | Assert.False(predicate(Diff(commit))); 34 | } 35 | 36 | [Fact] 37 | public void GivenTimeInESTAndCommitWasInADT_ReturnsFalse() 38 | { 39 | var commit = Commit().WithAuthor(new DateTimeOffset(TestTime, AdtOffset)); 40 | var predicate = OnOrAfter(new DateTimeOffset(TestTime, EstOffset).LocalDateTime); 41 | Assert.False(predicate(Diff(commit))); 42 | } 43 | 44 | [Fact] 45 | public void WhenEqualTo_ReturnTrue() 46 | { 47 | var commitTime = new DateTimeOffset(TestTime, EstOffset); 48 | var commit = Commit().WithAuthor(commitTime); 49 | var predicate = OnOrAfter(commitTime.LocalDateTime); 50 | Assert.True(predicate(Diff(commit))); 51 | } 52 | 53 | [Fact] 54 | public void GivenTimeInESTAndCommitWasCST_ReturnsTrue() 55 | { 56 | var commit = Commit().WithAuthor(new DateTimeOffset(TestTime, CstOffset)); 57 | var predicate = OnOrAfter(new DateTimeOffset(TestTime, EstOffset).LocalDateTime); 58 | Assert.True(predicate(Diff(commit))); 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /tests/gitnstats.core.tests/DiffCollectorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using GitNStats.Core; 6 | using GitNStats.Core.Tests; 7 | using LibGit2Sharp; 8 | using Xunit; 9 | using Moq; 10 | 11 | namespace GitNStats.Tests.Visitor 12 | { 13 | public class DiffWalkerTests 14 | { 15 | [Fact] 16 | public async Task WhenVisitorVisitsOneCommit_OneDiffIsReturned() 17 | { 18 | var commit = Fakes.Commit().Object; 19 | 20 | var visitor = new Mock(); 21 | visitor.Setup(v => v.Walk(commit)) 22 | .Raises(v => v.Visited += null, visitor.Object, commit); 23 | 24 | var listener = new Mock(); 25 | listener.Setup(l => l.OnCommitVisited(It.IsAny(), It.IsAny())) 26 | .Callback(()=> 27 | listener.SetupGet(l => l.Diffs) 28 | .Returns(new List() 29 | { 30 | new(commit, new Mock().Object) 31 | }) 32 | ); 33 | 34 | var walker = new DiffCollector(visitor.Object, listener.Object); 35 | var result = await walker.Walk(commit); 36 | 37 | Assert.Equal(commit.Sha, result.First().Commit.Sha); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /tests/gitnstats.core.tests/DiffListenerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using GitNStats.Core; 4 | using GitNStats.Core.Tests; 5 | using LibGit2Sharp; 6 | using Moq; 7 | using Xunit; 8 | 9 | namespace GitNStats.Tests 10 | { 11 | public class DiffListenerTests 12 | { 13 | [Fact] 14 | public void WhenCommitIsVisited_DiffWithItsParentIsStored() 15 | { 16 | //arrange 17 | var treeChangeA = new Mock(); 18 | treeChangeA.Setup(t => t.Path).Returns("a"); 19 | var treeChangeB = new Mock(); 20 | treeChangeB.Setup(t => t.Path).Returns("b"); 21 | var treeEntryChanges = new List() 22 | { 23 | treeChangeA.Object, 24 | treeChangeB.Object 25 | }; 26 | 27 | var expected = Fakes.TreeChanges(treeEntryChanges); 28 | 29 | var diff = new Mock(); 30 | diff.Setup(d => d.Compare(It.IsAny(), It.IsAny())) 31 | .Returns(expected.Object); 32 | var repo = new Mock(); 33 | repo.Setup(r => r.Diff) 34 | .Returns(diff.Object); 35 | 36 | var commit = Fakes.Commit().WithParents().Object; 37 | 38 | //act 39 | var listener = new DiffListener(repo.Object); 40 | listener.OnCommitVisited(new CommitVisitor(), commit); 41 | 42 | var actual = listener.Diffs 43 | .Select(d => d.Diff) 44 | .OrderBy(change => change.Path) 45 | .ToList(); 46 | 47 | //assert 48 | Assert.Equal(treeEntryChanges, actual); 49 | Assert.Equal(commit, listener.Diffs.Select(d => d.Commit).First()); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /tests/gitnstats.core.tests/Fakes.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using LibGit2Sharp; 6 | using Moq; 7 | 8 | namespace GitNStats.Core.Tests 9 | { 10 | public static class Fakes 11 | { 12 | public static Mock Commit() 13 | { 14 | var guid = Guid.NewGuid().ToString(); 15 | var commit = new Mock(); 16 | commit.Setup(c => c.Sha).Returns(guid); 17 | commit.WithParents(Enumerable.Empty()); 18 | return commit; 19 | } 20 | 21 | public static Mock WithParents(this Mock commit) 22 | { 23 | commit.WithParents(new List() {Commit().Object, Commit().Object}); 24 | return commit; 25 | } 26 | 27 | public static Mock WithParents(this Mock commit, IEnumerable parents) 28 | { 29 | commit.Setup(c => c.Parents).Returns(parents); 30 | return commit; 31 | } 32 | 33 | public static Mock WithAuthor(this Mock commit, Signature author) 34 | { 35 | commit.Setup(c => c.Author) 36 | .Returns(author); 37 | 38 | return commit; 39 | } 40 | 41 | public static Mock WithAuthor(this Mock commit, DateTimeOffset commitTime) 42 | { 43 | return commit.WithAuthor(Signature(commitTime)); 44 | } 45 | 46 | /* 47 | I don't really like having TreeChanges and Commit in the same file. 48 | But this does provide a very nice `Fakes.Commit().WithParents()` 49 | and `Fakes.TreeChanges(somelist)` interface. 50 | 51 | I'd love to find a way to keep the interface but separate these. 52 | I tried using Fake as the namespace, but wasn't happy with the result. 53 | */ 54 | 55 | public static Mock TreeEntryChanges(string filePath) 56 | { 57 | var treeChanges = new Mock(); 58 | treeChanges.Setup(t => t.Path).Returns(filePath); 59 | treeChanges.Setup(t => t.OldPath).Returns(filePath); 60 | treeChanges.Setup(t => t.Status).Returns(ChangeKind.Modified); 61 | 62 | return treeChanges; 63 | } 64 | 65 | public static Mock TreeChanges(IEnumerable treeEntryChanges) 66 | { 67 | var treeChanges = new Mock(); 68 | // Calling GetEnumerator doesn't actually enumerate the collection. 69 | // ReSharper disable PossibleMultipleEnumeration 70 | treeChanges.Setup(e => e.GetEnumerator()) 71 | .Returns(treeEntryChanges.GetEnumerator()); 72 | treeChanges.As() 73 | .Setup(e => e.GetEnumerator()) 74 | .Returns(treeEntryChanges.GetEnumerator()); 75 | treeChanges.As>() 76 | .Setup(e => e.GetEnumerator()) 77 | .Returns(treeEntryChanges.GetEnumerator()); 78 | // ReSharper restore PossibleMultipleEnumeration 79 | return treeChanges; 80 | } 81 | 82 | public static IEnumerable Diffs(Mock commit) 83 | { 84 | return new List() 85 | { 86 | Diff(commit) 87 | }; 88 | } 89 | 90 | public static CommitDiff Diff(Mock commit) => 91 | new(commit.Object, TreeEntryChanges("path/to/file").Object); 92 | 93 | public static Signature Signature(DateTimeOffset dateTimeOffset) => 94 | new("rubberduck", "rubberduck@example.com", dateTimeOffset); 95 | } 96 | } -------------------------------------------------------------------------------- /tests/gitnstats.core.tests/Visitor/Walk.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using GitNStats.Core; 3 | using GitNStats.Core.Tests; 4 | using LibGit2Sharp; 5 | using Xunit; 6 | 7 | namespace GitNStats.Tests.Visitor 8 | { 9 | public class Walk 10 | { 11 | [Fact] 12 | public void NoParents_OnlyVisitsCurrentCommit() 13 | { 14 | var commit = Fakes.Commit(); 15 | 16 | var visitor = new CommitVisitor(); 17 | 18 | var visitedCount = 0; 19 | visitor.Visited += (sender, visited) => visitedCount++; 20 | visitor.Walk(commit.Object); 21 | 22 | Assert.Equal(1, visitedCount); 23 | } 24 | 25 | [Fact] 26 | public void OneParent_VisitsCurrentCommitAndParent() 27 | { 28 | var commit = Fakes.Commit() 29 | .WithParents(new List() 30 | { 31 | Fakes.Commit().Object 32 | }); 33 | 34 | var visitor = new CommitVisitor(); 35 | 36 | var visitedCount = 0; 37 | visitor.Visited += (sender, visited) => visitedCount++; 38 | 39 | visitor.Walk(commit.Object); 40 | 41 | Assert.Equal(2, visitedCount); 42 | } 43 | 44 | [Fact] 45 | public void TwoParents_VisitsCurrentCommitAndBothParents() 46 | { 47 | var commit = Fakes.Commit() 48 | .WithParents(new List() 49 | { 50 | Fakes.Commit().Object, 51 | Fakes.Commit().Object 52 | }); 53 | 54 | var visitor = new CommitVisitor(); 55 | 56 | var visitedCount = 0; 57 | visitor.Visited += (sender, visited) => visitedCount++; 58 | 59 | visitor.Walk(commit.Object); 60 | 61 | Assert.Equal(3, visitedCount); 62 | } 63 | 64 | [Fact] 65 | public void ParentsShareAParent_OnlyVisitGrandParentOnce() 66 | { 67 | var grandParent = Fakes.Commit(); 68 | var grandParents = new List() { grandParent.Object }; 69 | 70 | var commit = Fakes.Commit() 71 | .WithParents(new List() 72 | { 73 | Fakes.Commit().WithParents(grandParents).Object, 74 | Fakes.Commit().WithParents(grandParents).Object 75 | }); 76 | 77 | var visitor = new CommitVisitor(); 78 | 79 | var visitedCount = 0; 80 | var grandParentVisitedCount = 0; 81 | visitor.Visited += (sender, visited) => { 82 | visitedCount++; 83 | if (visited.Sha == grandParent.Object.Sha) 84 | { 85 | grandParentVisitedCount++; 86 | } 87 | }; 88 | 89 | visitor.Walk(commit.Object); 90 | 91 | Assert.Equal(4, visitedCount); 92 | Assert.Equal(1, grandParentVisitedCount); 93 | } 94 | 95 | [Fact] 96 | public void CanCallWalkMultipleTimes() 97 | { 98 | var grandParent = Fakes.Commit(); 99 | var grandParents = new List() { grandParent.Object }; 100 | 101 | var commit = Fakes.Commit() 102 | .WithParents(new List() 103 | { 104 | Fakes.Commit().WithParents(grandParents).Object, 105 | Fakes.Commit().WithParents(grandParents).Object 106 | }); 107 | 108 | var visitor = new CommitVisitor(); 109 | 110 | var visitedCount = 0; 111 | var grandParentVisitedCount = 0; 112 | visitor.Visited += (sender, visited) => { 113 | visitedCount++; 114 | if (visited.Sha == grandParent.Object.Sha) 115 | { 116 | grandParentVisitedCount++; 117 | } 118 | }; 119 | 120 | visitor.Walk(commit.Object); 121 | visitor.Walk(commit.Object); 122 | 123 | Assert.Equal(8, visitedCount); 124 | Assert.Equal(2, grandParentVisitedCount); 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /tests/gitnstats.core.tests/gitnstats.core.tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | false 5 | 6 | 7 | 8 | 9 | 10 | 11 | all 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/gitnstats.test/ControllerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | 5 | using GitNStats; 6 | using GitNStats.Core; 7 | using GitNStats.Core.Tests; 8 | using LibGit2Sharp; 9 | using Moq; 10 | using Xunit; 11 | 12 | namespace gitnstats.test 13 | { 14 | public class ControllerTests 15 | { 16 | private readonly Mock _view; 17 | private readonly Mock _fileSystem; 18 | 19 | private readonly Mock _asyncVisitor; 20 | 21 | private readonly Mock _currentBranch; 22 | private readonly Mock _otherBranch; 23 | 24 | private readonly Controller _controller; 25 | 26 | public ControllerTests() 27 | { 28 | _view = new Mock(); 29 | _fileSystem = new Mock(); 30 | _fileSystem.Setup(fs => fs.CurrentDirectory()).Returns("/path/to/current/directory"); 31 | 32 | _currentBranch = new Mock(); 33 | _currentBranch.Setup(b => b.FriendlyName).Returns("master"); 34 | 35 | _otherBranch = new Mock(); 36 | _otherBranch.Setup(b => b.FriendlyName).Returns("next"); 37 | 38 | var branches = new Mock(); 39 | branches.Setup(b => b[_currentBranch.Object.FriendlyName]).Returns(_currentBranch.Object); 40 | branches.Setup(b => b[_otherBranch.Object.FriendlyName]).Returns(_otherBranch.Object); 41 | 42 | var repository = new Mock(); 43 | repository.SetupGet(r => r.Head).Returns(_currentBranch.Object); 44 | repository.Setup(r => r.Branches).Returns(branches.Object); 45 | 46 | IRepository RepositoryFactory(string repoPath) => repository.Object; 47 | 48 | _asyncVisitor = new Mock(); 49 | AsyncVisitor AsyncVisitorFactory(IRepository repo) => _asyncVisitor.Object; 50 | 51 | _controller = new Controller(_view.Object, _fileSystem.Object, RepositoryFactory, AsyncVisitorFactory); 52 | } 53 | 54 | [Fact] 55 | public async Task WhenNoRepositorySpecified_AssumesCurrentDirectory() 56 | { 57 | var options = new Options(); 58 | 59 | var result = await _controller.RunAnalysis(options); 60 | 61 | _fileSystem.Verify(fs => fs.CurrentDirectory(), Times.Once); 62 | Assert.Equal(Result.Success, result); 63 | } 64 | 65 | [Fact] 66 | public async Task WhenRepositoryFieldIsSpecified_UseIt() 67 | { 68 | var options = new Options() {RepositoryPath = "path/to/repo"}; 69 | var result = await _controller.RunAnalysis(options); 70 | 71 | _fileSystem.Verify(fs => fs.CurrentDirectory(), Times.Never); 72 | Assert.Equal(Result.Success, result); 73 | } 74 | 75 | [Fact] 76 | public async Task WhenInQuietMode_DoNotDisplayRepositoryInfo() 77 | { 78 | _view.SetupProperty(v => v.QuietMode, false); 79 | var options = new Options() { QuietMode = true }; 80 | var result = await _controller.RunAnalysis(options); 81 | 82 | Assert.True(_view.Object.QuietMode); 83 | } 84 | 85 | [Fact] 86 | public async Task WhenNoBranchIsSpecified_CurrentBranchNameIsDisplayed() 87 | { 88 | var options = new Options(); 89 | var result = await _controller.RunAnalysis(options); 90 | 91 | _view.Verify(v => v.DisplayRepositoryInfo(_fileSystem.Object.CurrentDirectory(), _currentBranch.Object), Times.Once); 92 | } 93 | 94 | [Fact] 95 | public async Task WhenBranchIsSpecified_ThatBranchNameIsDisplayed() 96 | { 97 | var options = new Options() { BranchName = _otherBranch.Object.FriendlyName }; 98 | await _controller.RunAnalysis(options); 99 | 100 | _view.Verify(v => v.DisplayRepositoryInfo(_fileSystem.Object.CurrentDirectory(), _otherBranch.Object), Times.Once); 101 | } 102 | 103 | [Fact] 104 | public async Task WhenSpecifiedBranchDoesNotExist_DisplayError() 105 | { 106 | var options = new Options() { BranchName = "applesauce" }; 107 | await _controller.RunAnalysis(options); 108 | 109 | _view.Verify(v => v.DisplayError("Invalid branch: applesauce"), Times.Once); 110 | } 111 | 112 | [Fact] 113 | public async Task WhenSpecifiedBranchDoesNotExist_ReturnsFailure() 114 | { 115 | var options = new Options() { BranchName = "applesauce" }; 116 | var result = await _controller.RunAnalysis(options); 117 | 118 | Assert.Equal(Result.Failure, result); 119 | } 120 | 121 | [Fact] 122 | public async Task WhenRepositoryPathIsNotARepository_DisplayError() 123 | { 124 | IRepository RepositoryFactory(string repoPath) => throw new RepositoryNotFoundException(); 125 | 126 | var controller = new Controller(_view.Object, _fileSystem.Object, RepositoryFactory, (repo) => null); 127 | 128 | await controller.RunAnalysis(new Options()); 129 | 130 | _view.Verify(v => v.DisplayError($"{_fileSystem.Object.CurrentDirectory()} is not a git repository.")); 131 | } 132 | 133 | [Fact] 134 | public async Task WhenRepositoryPathIsNotARepository_ReturnFailure() 135 | { 136 | IRepository RepositoryFactory(string repoPath) => throw new RepositoryNotFoundException(); 137 | var controller = new Controller(_view.Object, _fileSystem.Object, RepositoryFactory, (repo) => null); 138 | 139 | var result = await controller.RunAnalysis(new Options()); 140 | 141 | Assert.Equal(Result.Failure, result); 142 | } 143 | 144 | [Fact] 145 | public async Task DisplaysPathCounts() 146 | { 147 | var diffs = Enumerable.Empty(); 148 | 149 | _asyncVisitor.Setup(v => v.Walk(It.IsAny())) 150 | .Returns(Task.FromResult(diffs)); 151 | 152 | var pathCounts = Analysis.CountFileChanges(diffs); 153 | 154 | var result = await _controller.RunAnalysis(new Options()); 155 | 156 | _view.Verify(v => v.DisplayPathCounts(pathCounts)); 157 | Assert.Equal(Result.Success, result); 158 | } 159 | 160 | [Fact] 161 | public async Task WhenDateFilterIsPresent_DisplayFilteredResults() 162 | { 163 | var diffs = Fakes.Diffs(Fakes.Commit().WithAuthor(DateTimeOffset.Parse("2017-06-21 13:30 -4:00"))); 164 | 165 | _asyncVisitor.Setup(v => v.Walk(It.IsAny())) 166 | .Returns(Task.FromResult(diffs)); 167 | 168 | var result = await _controller.RunAnalysis(new Options(){DateFilter = DateTime.Parse("2017-07-21")}); 169 | 170 | var pathCounts = Analysis.CountFileChanges(Enumerable.Empty()); 171 | _view.Verify(v => v.DisplayPathCounts(pathCounts)); 172 | Assert.Equal(Result.Success, result); 173 | } 174 | } 175 | } -------------------------------------------------------------------------------- /tests/gitnstats.test/gitnstats.test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | false 5 | 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | 23 | 24 | 25 | 26 | --------------------------------------------------------------------------------