├── .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 | [](https://ci.appveyor.com/project/Rubberduck/gitnstats/branch/master)
10 | [](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 |
--------------------------------------------------------------------------------