gitlogstats 1.4.0__tar.gz → 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/PKG-INFO +28 -13
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/README.md +27 -12
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/docs/SPEC.md +7 -1
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/pyproject.toml +1 -1
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/manifest.yaml +17 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/__main__.py +20 -0
- gitlogstats-1.5.0/src/gitlogstats/clones.py +85 -0
- gitlogstats-1.5.0/tests/test_clobber.py +252 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/CLAUDE.md +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/agents/developer-agent.md +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/agents/spec-reviewer.md +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/format-edited.sh +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/guard-paths.sh +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/guard-paths.test.mjs +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/verify.sh +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/settings.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/skills/stale-check/SKILL.md +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.github/workflows/build.yaml +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.gitignore +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.prettierrc.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/LICENSE +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/Pipfile +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/Pipfile.lock +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/package-lock.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/package.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/repos.txt +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/requirements.txt +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/config.mjs +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/derive.mjs +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/derive.test.mjs +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/sync.mjs +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/__init__.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/git_logs_parser.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/github.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/github_stats.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/identity.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fake_gh.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/commit_linked.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/commit_unlinked.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/contributors.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/contributors_page2.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/pulls_page1.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/pulls_page2.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/user_full.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/user_private.json +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/test_git_logs_parser.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/test_github.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/test_identity.py +0 -0
- {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/test_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitlogstats
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Track developers' contributions to one or more git repositories within a particular time range.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bloombar/gitlogstats
|
|
6
6
|
Project-URL: Repository, https://github.com/bloombar/gitlogstats.git
|
|
@@ -709,7 +709,7 @@ This script calculates the following, for each developer in each repository:
|
|
|
709
709
|
- number of **files changed**
|
|
710
710
|
- number of **merges** into the analyzed branch
|
|
711
711
|
|
|
712
|
-
With `--github`, it also reports each developer's GitHub **pull requests** opened, merged, closed without merging and merged by themselves, their **pushes** to the analyzed branch
|
|
712
|
+
With `--github`, it also reports each developer's GitHub **pull requests** opened, merged, closed without merging and merged by themselves, their **pushes** to the analyzed branch, their commits made on GitHub's **website**, and their **git and GitHub email addresses**. It matches each GitHub account to the developer's git names and combines a developer who committed under several names into one row (see [GitHub statistics](#github-statistics)).
|
|
713
713
|
|
|
714
714
|
The results can be formatted as `csv`, `json`, or `markdown`.
|
|
715
715
|
|
|
@@ -726,7 +726,7 @@ The command `gitlogstats --help` shows the usage instructions:
|
|
|
726
726
|
```
|
|
727
727
|
usage: gitlogstats [-h] (-r REPOSITORY | -rf REPOFILE) [-u USER] [-s START]
|
|
728
728
|
[-e END] [-x EXCLUSIONS] [-f {csv,json,markdown}]
|
|
729
|
-
[-b BRANCH] [-v] [-c] [--github]
|
|
729
|
+
[-b BRANCH] [-v] [-c] [--github] [--clobber]
|
|
730
730
|
|
|
731
731
|
options:
|
|
732
732
|
-h, --help show this help message and exit
|
|
@@ -757,6 +757,9 @@ options:
|
|
|
757
757
|
and their commits made on the GitHub website, and
|
|
758
758
|
match GitHub users to git authors. Needs the GitHub
|
|
759
759
|
CLI (gh), signed in
|
|
760
|
+
--clobber Delete the repository clones this run used from the
|
|
761
|
+
repos/ directory after printing the results (default:
|
|
762
|
+
keep them for faster later runs)
|
|
760
763
|
```
|
|
761
764
|
|
|
762
765
|
In case `gitlogstats` is not found as a command, even after installing, try `python -m gitlogstats` or `python3 -m gitlogstats` instead.
|
|
@@ -837,9 +840,19 @@ By default, every contributor in a repository's history is listed, with zeros fo
|
|
|
837
840
|
gitlogstats -rf repos.txt -c
|
|
838
841
|
```
|
|
839
842
|
|
|
840
|
-
###
|
|
843
|
+
### Cloned repositories
|
|
841
844
|
|
|
842
|
-
Add `--
|
|
845
|
+
Each repository is cloned into a `repos/` directory under the directory you run the command from, and later runs update (`git pull`) the existing clone instead of cloning again, which makes them much faster. Add `--clobber` to delete the clones the run used, once the results have been printed; `repos/` itself is removed too if nothing else is left in it. Clones from other runs are kept, and nothing is deleted if the run fails before printing, so you can inspect it. With `-v`, each deleted path is listed on standard error.
|
|
846
|
+
|
|
847
|
+
```
|
|
848
|
+
gitlogstats -rf repos.txt --clobber
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
`--clobber` is unrelated to `-c/--clean`, which only hides contributors without activity.
|
|
852
|
+
|
|
853
|
+
### GitHub statistics
|
|
854
|
+
|
|
855
|
+
Add `--github` to also report each contributor's pull requests, pushes, website commits and email addresses. Nine columns are appended to every output format:
|
|
843
856
|
|
|
844
857
|
| Column | Meaning |
|
|
845
858
|
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
@@ -864,7 +877,7 @@ gitlogstats: --github needs the GitHub CLI (gh), which is not installed. Install
|
|
|
864
877
|
gitlogstats: --github needs the GitHub CLI (gh) to be signed in. Run 'gh auth login', or run without --github.
|
|
865
878
|
```
|
|
866
879
|
|
|
867
|
-
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests but has no commits is listed in a row of their own with an empty `username` and zero git statistics. When GitHub could not be asked, `git_email` is still filled in and `github_email` is empty. The emails come from the whole history of the branch, not only from commits in the date range.
|
|
880
|
+
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests or pushed but has no commits is listed in a row of their own with an empty `username` and zero git statistics. When GitHub could not be asked, `git_email` is still filled in and `github_email` is empty. The emails come from the whole history of the branch, not only from commits in the date range.
|
|
868
881
|
|
|
869
882
|
**Limits of pushes and website commits.** Pushes come from GitHub's repository activity record, which starts in September 2023: earlier pushes are not counted, however old the date range. A file edited on the website directly on the analyzed branch is recorded by GitHub as a push too, so it counts in both `pushes_to_default` and `web_commits`. Reading it works for any public repository you can see; for a private one you need access to it, and if GitHub refuses, the repository's GitHub columns are empty with the reason on standard error, as above. A push is credited to the GitHub user who pushed it, so a person who only pushes (and has no commits or pull requests in the range) still gets a row of their own. Pushes made while a tag or commit (not a branch) is checked out with `-b` are not counted. Website commits are recognised by GitHub being the committer, so commits made through GitHub's API, for example in the `github.dev` editor, are counted as website commits too, and so is the first commit GitHub creates when a repository is created on the website. Website commits follow `commits`: the same date range, exclusions (`-x`) and branch, credited to the commit's author and added up when names are combined into one row.
|
|
870
883
|
|
|
@@ -878,11 +891,11 @@ If a step finds several users it is skipped as ambiguous and the next is tried.
|
|
|
878
891
|
|
|
879
892
|
**How the other options apply.**
|
|
880
893
|
|
|
881
|
-
- `-s` / `-e`: each pull request count uses its own timestamp (opened, merged or closed) against the same inclusive local-time range as commits.
|
|
882
|
-
- `-b`: only pull requests whose base branch is that branch are counted
|
|
894
|
+
- `-s` / `-e`: each pull request count uses its own timestamp (opened, merged or closed), and each push its push time, against the same inclusive local-time range as commits.
|
|
895
|
+
- `-b`: only pull requests whose base branch is that branch are counted (without `-b`, all pull requests count), and pushes and website commits are counted on that branch instead of the default branch.
|
|
883
896
|
- `-u`: selects the combined row whose author names or emails equal the value exactly, or whose GitHub username equals it ignoring case. The whole combined row is reported, so `-u` with one email address also includes the person's commits under their other names and emails, and a value that matches nobody prints no row.
|
|
884
|
-
- `-c`: removes only rows with no git activity
|
|
885
|
-
- `-x`: has no effect on pull requests.
|
|
897
|
+
- `-c`: removes only rows with no git activity, no pull requests and no pushes. Email addresses do not count as activity.
|
|
898
|
+
- `-x`: has no effect on pull requests or pushes; it does apply to `web_commits`, which only counts commits that `commits` counts.
|
|
886
899
|
|
|
887
900
|
### Formatting the results
|
|
888
901
|
|
|
@@ -924,7 +937,7 @@ Some merges are not counted:
|
|
|
924
937
|
- Merges on other branches, unless that branch is analyzed with `-b`.
|
|
925
938
|
- Local merges that were never pushed.
|
|
926
939
|
|
|
927
|
-
A merge made on GitHub uses the person's GitHub profile name and email, which may differ from their local git settings. They can then appear under two names (see [Redundant usernames](#redundant-usernames)).
|
|
940
|
+
A merge made on GitHub uses the person's GitHub profile name and email, which may differ from their local git settings. They can then appear under two names; `--github` combines them into one row (see [Redundant usernames](#redundant-usernames)).
|
|
928
941
|
|
|
929
942
|
Each commit is credited to its author's name, after applying the repository's `.mailmap` if it has one. Merges are credited the same way. Commit messages are never read, so statistics are the same whether messages are one line, many lines, or empty.
|
|
930
943
|
|
|
@@ -949,6 +962,8 @@ Most likely, the contributor has failed to update their version control settings
|
|
|
949
962
|
|
|
950
963
|
### Redundant usernames
|
|
951
964
|
|
|
952
|
-
The output of the script sometimes lists the same individual contributor under more than one git username
|
|
965
|
+
The output of the script sometimes lists the same individual contributor under more than one git username. This is most likely due to different username settings for various git and GitHub clients: for example, a merge made on GitHub's website uses the person's GitHub display name, while their own commits use their local `user.name`.
|
|
966
|
+
|
|
967
|
+
With `--github`, names that belong to the same GitHub account are combined into one row automatically, with their statistics added up (see [GitHub statistics](#github-statistics)). Check the `github_username` and `git_email` columns to see which names were combined. A name GitHub cannot link to any account, for example one whose email is a laptop's default address, keeps a row of its own; add the two names to a `.mailmap` file to combine them.
|
|
953
968
|
|
|
954
|
-
|
|
969
|
+
Without `--github`, or for a repository not on GitHub, add up the rows of a developer who appears under several names yourself, or map the names together in a `.mailmap` file.
|
|
@@ -12,7 +12,7 @@ This script calculates the following, for each developer in each repository:
|
|
|
12
12
|
- number of **files changed**
|
|
13
13
|
- number of **merges** into the analyzed branch
|
|
14
14
|
|
|
15
|
-
With `--github`, it also reports each developer's GitHub **pull requests** opened, merged, closed without merging and merged by themselves, their **pushes** to the analyzed branch
|
|
15
|
+
With `--github`, it also reports each developer's GitHub **pull requests** opened, merged, closed without merging and merged by themselves, their **pushes** to the analyzed branch, their commits made on GitHub's **website**, and their **git and GitHub email addresses**. It matches each GitHub account to the developer's git names and combines a developer who committed under several names into one row (see [GitHub statistics](#github-statistics)).
|
|
16
16
|
|
|
17
17
|
The results can be formatted as `csv`, `json`, or `markdown`.
|
|
18
18
|
|
|
@@ -29,7 +29,7 @@ The command `gitlogstats --help` shows the usage instructions:
|
|
|
29
29
|
```
|
|
30
30
|
usage: gitlogstats [-h] (-r REPOSITORY | -rf REPOFILE) [-u USER] [-s START]
|
|
31
31
|
[-e END] [-x EXCLUSIONS] [-f {csv,json,markdown}]
|
|
32
|
-
[-b BRANCH] [-v] [-c] [--github]
|
|
32
|
+
[-b BRANCH] [-v] [-c] [--github] [--clobber]
|
|
33
33
|
|
|
34
34
|
options:
|
|
35
35
|
-h, --help show this help message and exit
|
|
@@ -60,6 +60,9 @@ options:
|
|
|
60
60
|
and their commits made on the GitHub website, and
|
|
61
61
|
match GitHub users to git authors. Needs the GitHub
|
|
62
62
|
CLI (gh), signed in
|
|
63
|
+
--clobber Delete the repository clones this run used from the
|
|
64
|
+
repos/ directory after printing the results (default:
|
|
65
|
+
keep them for faster later runs)
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
In case `gitlogstats` is not found as a command, even after installing, try `python -m gitlogstats` or `python3 -m gitlogstats` instead.
|
|
@@ -140,9 +143,19 @@ By default, every contributor in a repository's history is listed, with zeros fo
|
|
|
140
143
|
gitlogstats -rf repos.txt -c
|
|
141
144
|
```
|
|
142
145
|
|
|
143
|
-
###
|
|
146
|
+
### Cloned repositories
|
|
144
147
|
|
|
145
|
-
Add `--
|
|
148
|
+
Each repository is cloned into a `repos/` directory under the directory you run the command from, and later runs update (`git pull`) the existing clone instead of cloning again, which makes them much faster. Add `--clobber` to delete the clones the run used, once the results have been printed; `repos/` itself is removed too if nothing else is left in it. Clones from other runs are kept, and nothing is deleted if the run fails before printing, so you can inspect it. With `-v`, each deleted path is listed on standard error.
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
gitlogstats -rf repos.txt --clobber
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`--clobber` is unrelated to `-c/--clean`, which only hides contributors without activity.
|
|
155
|
+
|
|
156
|
+
### GitHub statistics
|
|
157
|
+
|
|
158
|
+
Add `--github` to also report each contributor's pull requests, pushes, website commits and email addresses. Nine columns are appended to every output format:
|
|
146
159
|
|
|
147
160
|
| Column | Meaning |
|
|
148
161
|
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
@@ -167,7 +180,7 @@ gitlogstats: --github needs the GitHub CLI (gh), which is not installed. Install
|
|
|
167
180
|
gitlogstats: --github needs the GitHub CLI (gh) to be signed in. Run 'gh auth login', or run without --github.
|
|
168
181
|
```
|
|
169
182
|
|
|
170
|
-
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests but has no commits is listed in a row of their own with an empty `username` and zero git statistics. When GitHub could not be asked, `git_email` is still filled in and `github_email` is empty. The emails come from the whole history of the branch, not only from commits in the date range.
|
|
183
|
+
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests or pushed but has no commits is listed in a row of their own with an empty `username` and zero git statistics. When GitHub could not be asked, `git_email` is still filled in and `github_email` is empty. The emails come from the whole history of the branch, not only from commits in the date range.
|
|
171
184
|
|
|
172
185
|
**Limits of pushes and website commits.** Pushes come from GitHub's repository activity record, which starts in September 2023: earlier pushes are not counted, however old the date range. A file edited on the website directly on the analyzed branch is recorded by GitHub as a push too, so it counts in both `pushes_to_default` and `web_commits`. Reading it works for any public repository you can see; for a private one you need access to it, and if GitHub refuses, the repository's GitHub columns are empty with the reason on standard error, as above. A push is credited to the GitHub user who pushed it, so a person who only pushes (and has no commits or pull requests in the range) still gets a row of their own. Pushes made while a tag or commit (not a branch) is checked out with `-b` are not counted. Website commits are recognised by GitHub being the committer, so commits made through GitHub's API, for example in the `github.dev` editor, are counted as website commits too, and so is the first commit GitHub creates when a repository is created on the website. Website commits follow `commits`: the same date range, exclusions (`-x`) and branch, credited to the commit's author and added up when names are combined into one row.
|
|
173
186
|
|
|
@@ -181,11 +194,11 @@ If a step finds several users it is skipped as ambiguous and the next is tried.
|
|
|
181
194
|
|
|
182
195
|
**How the other options apply.**
|
|
183
196
|
|
|
184
|
-
- `-s` / `-e`: each pull request count uses its own timestamp (opened, merged or closed) against the same inclusive local-time range as commits.
|
|
185
|
-
- `-b`: only pull requests whose base branch is that branch are counted
|
|
197
|
+
- `-s` / `-e`: each pull request count uses its own timestamp (opened, merged or closed), and each push its push time, against the same inclusive local-time range as commits.
|
|
198
|
+
- `-b`: only pull requests whose base branch is that branch are counted (without `-b`, all pull requests count), and pushes and website commits are counted on that branch instead of the default branch.
|
|
186
199
|
- `-u`: selects the combined row whose author names or emails equal the value exactly, or whose GitHub username equals it ignoring case. The whole combined row is reported, so `-u` with one email address also includes the person's commits under their other names and emails, and a value that matches nobody prints no row.
|
|
187
|
-
- `-c`: removes only rows with no git activity
|
|
188
|
-
- `-x`: has no effect on pull requests.
|
|
200
|
+
- `-c`: removes only rows with no git activity, no pull requests and no pushes. Email addresses do not count as activity.
|
|
201
|
+
- `-x`: has no effect on pull requests or pushes; it does apply to `web_commits`, which only counts commits that `commits` counts.
|
|
189
202
|
|
|
190
203
|
### Formatting the results
|
|
191
204
|
|
|
@@ -227,7 +240,7 @@ Some merges are not counted:
|
|
|
227
240
|
- Merges on other branches, unless that branch is analyzed with `-b`.
|
|
228
241
|
- Local merges that were never pushed.
|
|
229
242
|
|
|
230
|
-
A merge made on GitHub uses the person's GitHub profile name and email, which may differ from their local git settings. They can then appear under two names (see [Redundant usernames](#redundant-usernames)).
|
|
243
|
+
A merge made on GitHub uses the person's GitHub profile name and email, which may differ from their local git settings. They can then appear under two names; `--github` combines them into one row (see [Redundant usernames](#redundant-usernames)).
|
|
231
244
|
|
|
232
245
|
Each commit is credited to its author's name, after applying the repository's `.mailmap` if it has one. Merges are credited the same way. Commit messages are never read, so statistics are the same whether messages are one line, many lines, or empty.
|
|
233
246
|
|
|
@@ -252,6 +265,8 @@ Most likely, the contributor has failed to update their version control settings
|
|
|
252
265
|
|
|
253
266
|
### Redundant usernames
|
|
254
267
|
|
|
255
|
-
The output of the script sometimes lists the same individual contributor under more than one git username
|
|
268
|
+
The output of the script sometimes lists the same individual contributor under more than one git username. This is most likely due to different username settings for various git and GitHub clients: for example, a merge made on GitHub's website uses the person's GitHub display name, while their own commits use their local `user.name`.
|
|
269
|
+
|
|
270
|
+
With `--github`, names that belong to the same GitHub account are combined into one row automatically, with their statistics added up (see [GitHub statistics](#github-statistics)). Check the `github_username` and `git_email` columns to see which names were combined. A name GitHub cannot link to any account, for example one whose email is a laptop's default address, keeps a row of its own; add the two names to a `.mailmap` file to combine them.
|
|
256
271
|
|
|
257
|
-
|
|
272
|
+
Without `--github`, or for a repository not on GitHub, add up the rows of a developer who appears under several names yourself, or map the names together in a `.mailmap` file.
|
|
@@ -29,7 +29,7 @@ This document describes what the tool does today and what is planned. Each requi
|
|
|
29
29
|
|
|
30
30
|
#### REPO-3 Local clone cache
|
|
31
31
|
|
|
32
|
-
Repositories are cloned into a `repos/` directory under the current working directory, in a folder named the way `git clone` names it (the last path segment, minus a trailing `.git`; dots in the name are kept). On later runs the existing clone is updated with `git pull` instead of being cloned again.
|
|
32
|
+
Repositories are cloned into a `repos/` directory under the current working directory, in a folder named the way `git clone` names it (the last path segment, minus a trailing `.git`; dots in the name are kept). On later runs the existing clone is updated with `git pull` instead of being cloned again. `--clobber` removes the clones after the run (REPO-5).
|
|
33
33
|
|
|
34
34
|
**Status:** Done
|
|
35
35
|
|
|
@@ -39,6 +39,12 @@ Repositories are cloned into a `repos/` directory under the current working dire
|
|
|
39
39
|
|
|
40
40
|
**Status:** Done
|
|
41
41
|
|
|
42
|
+
#### REPO-5 Remove clones after the run
|
|
43
|
+
|
|
44
|
+
`--clobber` deletes the clones this run used from the `repos/` directory (REPO-3) once the results have been printed, and then removes `repos/` itself if nothing else is left in it. Clones in `repos/` that this run did not use are kept. It is off by default, so clones are kept and reused by later runs. If the run fails before the results are printed, nothing is deleted, so the clones can be inspected. Only directories inside `repos/` are deleted; a symbolic link in place of a clone is removed without following it. If `repos/` is itself a symbolic link to another directory, the clones are deleted from that directory and the link is kept. A clone that cannot be fully deleted (e.g. it contains a folder the user cannot open) is reported on standard error and the remaining clones are still deleted; the exit status stays 0, since the results were printed. Read-only files that git writes inside a clone (as on Windows) are deleted too. With `-v`, each deleted path is printed to standard error. The name follows build tools such as Rake, where `clean` removes intermediate files and `clobber` also removes caches; it is unrelated to `-c/--clean` (FILT-4).
|
|
45
|
+
|
|
46
|
+
**Status:** Done
|
|
47
|
+
|
|
42
48
|
### 2. Contribution Statistics
|
|
43
49
|
|
|
44
50
|
#### STAT-1 Commit count
|
|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitlogstats"
|
|
7
7
|
description = "Track developers' contributions to one or more git repositories within a particular time range."
|
|
8
|
-
version = "1.
|
|
8
|
+
version = "1.5.0"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Tobody Tothing", email="nobody.nothing@nowhere.com" },
|
|
11
11
|
]
|
|
@@ -194,6 +194,7 @@
|
|
|
194
194
|
directory, in a folder named the way git clone names it (the last path
|
|
195
195
|
segment, minus a trailing .git; dots in the name are kept). On later runs
|
|
196
196
|
the existing clone is updated with git pull instead of being cloned again.
|
|
197
|
+
--clobber removes the clones after the run (REPO-5).
|
|
197
198
|
section: §1 Repository Selection
|
|
198
199
|
family: REPO
|
|
199
200
|
phase: 1
|
|
@@ -209,6 +210,22 @@
|
|
|
209
210
|
family: REPO
|
|
210
211
|
phase: 1
|
|
211
212
|
status: Done
|
|
213
|
+
- id: REPO-5
|
|
214
|
+
title: Remove clones after the run
|
|
215
|
+
full: --clobber deletes the clones this run used from the repos/ directory
|
|
216
|
+
(REPO-3) once the results have been printed, and then removes repos/ itself
|
|
217
|
+
if nothing else is left in it. Clones in repos/ that this run did not use
|
|
218
|
+
are kept. It is off by default, so clones are kept and reused by later runs.
|
|
219
|
+
If the run fails before the results are printed, nothing is deleted, so the
|
|
220
|
+
clones can be inspected. Only directories inside repos/ are deleted; a
|
|
221
|
+
symbolic link in place of a clone is removed without following it. If repos/
|
|
222
|
+
is itself a symbolic link to another directory, the clones are deleted from
|
|
223
|
+
that directory and the link is kept. A clone that cannot be fully deleted
|
|
224
|
+
(e.g. it contains a folder…
|
|
225
|
+
section: §1 Repository Selection
|
|
226
|
+
family: REPO
|
|
227
|
+
phase: 1
|
|
228
|
+
status: Done
|
|
212
229
|
- id: STAT-1
|
|
213
230
|
title: Commit count
|
|
214
231
|
full: "For each contributor, the number of non-merge commits they authored in
|
|
@@ -5,6 +5,7 @@ import datetime
|
|
|
5
5
|
import re
|
|
6
6
|
import sys
|
|
7
7
|
from . import GitLogsParser
|
|
8
|
+
from .clones import remove_clones
|
|
8
9
|
from .git_logs_parser import FIELDNAMES
|
|
9
10
|
from .github import (
|
|
10
11
|
ACTIVITY_FIELDNAMES,
|
|
@@ -155,6 +156,14 @@ def main():
|
|
|
155
156
|
default=False,
|
|
156
157
|
action="store_true",
|
|
157
158
|
)
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
"--clobber",
|
|
161
|
+
help="Delete the repository clones this run used from the repos/ "
|
|
162
|
+
"directory after printing the results (default: keep them for faster "
|
|
163
|
+
"later runs)",
|
|
164
|
+
default=False,
|
|
165
|
+
action="store_true",
|
|
166
|
+
)
|
|
158
167
|
args = parser.parse_args()
|
|
159
168
|
|
|
160
169
|
if datetime.datetime.strptime(args.start, "%m/%d/%Y") > datetime.datetime.strptime(
|
|
@@ -179,6 +188,7 @@ def main():
|
|
|
179
188
|
with open(args.repofile, "r", encoding="utf8") as f:
|
|
180
189
|
repository_urls = GitLogsParser.read_repository_urls(f.read())
|
|
181
190
|
|
|
191
|
+
start_dir = os.getcwd() # REPO-5: where the command was started from
|
|
182
192
|
# navigate into the directory where repos will be stored
|
|
183
193
|
repos_dir = os.path.join(
|
|
184
194
|
os.getcwd(), "repos"
|
|
@@ -190,6 +200,7 @@ def main():
|
|
|
190
200
|
# loop through each git repository url, accumulating all results
|
|
191
201
|
all_results = []
|
|
192
202
|
last_parser = None
|
|
203
|
+
used_clones = [] # REPO-5: the clone directories this run cloned or pulled
|
|
193
204
|
for repo_url in repository_urls:
|
|
194
205
|
os.chdir(repos_dir) # start from the parent directory of all repos
|
|
195
206
|
|
|
@@ -221,6 +232,8 @@ def main():
|
|
|
221
232
|
["git", "pull"], capture_output=True, check=True
|
|
222
233
|
) # get the latest code from github
|
|
223
234
|
|
|
235
|
+
used_clones.append(repo_dir)
|
|
236
|
+
|
|
224
237
|
# with --github, -u and -c are applied after GitHub users are combined
|
|
225
238
|
last_parser = GitLogsParser(
|
|
226
239
|
repo=repo_dir,
|
|
@@ -250,6 +263,13 @@ def main():
|
|
|
250
263
|
# end with exactly one newline: CSV and Markdown output already has one
|
|
251
264
|
print(output, end="" if output.endswith("\n") else "\n")
|
|
252
265
|
|
|
266
|
+
# REPO-5: only now that the results are out, delete the clones. Leave the
|
|
267
|
+
# clone we were last inside first, so the deletion never depends on it.
|
|
268
|
+
if args.clobber:
|
|
269
|
+
sys.stdout.flush()
|
|
270
|
+
os.chdir(start_dir)
|
|
271
|
+
remove_clones(repos_dir, used_clones, args.verbose)
|
|
272
|
+
|
|
253
273
|
|
|
254
274
|
# if this script is being run directly...
|
|
255
275
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Deleting the repository clones a run used (REPO-5, --clobber)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_writable_and_retry(function, path, exc_info):
|
|
10
|
+
"""
|
|
11
|
+
Error handler for shutil.rmtree: git marks some files read-only (notably
|
|
12
|
+
pack files on Windows), which stops them being deleted. Clear the read-only
|
|
13
|
+
bit on the file and its folder, then try again. Any other error is raised,
|
|
14
|
+
as is a failure to open or list a folder: retrying os.open or os.scandir
|
|
15
|
+
with only a path would itself fail with a TypeError.
|
|
16
|
+
"""
|
|
17
|
+
if not isinstance(exc_info[1], PermissionError) or function not in (
|
|
18
|
+
os.unlink,
|
|
19
|
+
os.remove,
|
|
20
|
+
os.rmdir,
|
|
21
|
+
):
|
|
22
|
+
raise exc_info[1]
|
|
23
|
+
for target in (os.path.dirname(path), path):
|
|
24
|
+
try:
|
|
25
|
+
os.chmod(target, os.stat(target).st_mode | stat.S_IRWXU)
|
|
26
|
+
except OSError:
|
|
27
|
+
pass
|
|
28
|
+
function(path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _delete(path):
|
|
32
|
+
"""
|
|
33
|
+
Delete one clone. A symbolic link is unlinked, never followed.
|
|
34
|
+
@param path: the clone's path
|
|
35
|
+
"""
|
|
36
|
+
if os.path.islink(path) or not os.path.isdir(path):
|
|
37
|
+
os.remove(path)
|
|
38
|
+
else:
|
|
39
|
+
shutil.rmtree(path, onerror=_make_writable_and_retry)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def remove_clones(repos_dir, clone_dirs, verbose=False):
|
|
43
|
+
"""
|
|
44
|
+
Delete the given clones, then remove repos_dir if nothing else is left in it.
|
|
45
|
+
Only direct children of repos_dir are deleted; any other path is skipped.
|
|
46
|
+
A failed deletion is reported on standard error and does not stop the rest.
|
|
47
|
+
@param repos_dir: the directory holding the clones
|
|
48
|
+
@param clone_dirs: the paths of the clones to delete (duplicates are fine)
|
|
49
|
+
@param verbose: whether to report each deleted or skipped path on stderr
|
|
50
|
+
"""
|
|
51
|
+
real_repos = os.path.realpath(repos_dir)
|
|
52
|
+
seen = set()
|
|
53
|
+
for clone_dir in clone_dirs:
|
|
54
|
+
path = os.path.normpath(os.path.abspath(clone_dir))
|
|
55
|
+
if path in seen:
|
|
56
|
+
continue
|
|
57
|
+
seen.add(path)
|
|
58
|
+
# the folder that holds the clone must be repos_dir itself; checking the
|
|
59
|
+
# parent (not the path) means a symbolic link is judged by where it sits
|
|
60
|
+
if os.path.realpath(os.path.dirname(path)) != real_repos:
|
|
61
|
+
if verbose:
|
|
62
|
+
print(
|
|
63
|
+
f"gitlogstats: not deleting {path}: not inside {repos_dir}",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
continue
|
|
67
|
+
if not os.path.lexists(path):
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
_delete(path)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
print(
|
|
73
|
+
f"gitlogstats: could not delete {path}: {exc.strerror or exc}",
|
|
74
|
+
file=sys.stderr,
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
if verbose:
|
|
78
|
+
print(f"gitlogstats: deleted {path}", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
os.rmdir(repos_dir) # only succeeds when empty
|
|
82
|
+
except OSError:
|
|
83
|
+
return
|
|
84
|
+
if verbose:
|
|
85
|
+
print(f"gitlogstats: deleted {repos_dir}", file=sys.stderr)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Tests for --clobber (REPO-5): deleting the clones a run used."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from gitlogstats import clones
|
|
11
|
+
from gitlogstats.__main__ import main
|
|
12
|
+
from gitlogstats.clones import remove_clones
|
|
13
|
+
from test_integration import (
|
|
14
|
+
DATES,
|
|
15
|
+
GITHUB_URL,
|
|
16
|
+
Repo,
|
|
17
|
+
github_world,
|
|
18
|
+
lines,
|
|
19
|
+
pr,
|
|
20
|
+
run_github,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def remotes(tmp_path):
|
|
26
|
+
"""Two 'remote' repositories that the command clones by path."""
|
|
27
|
+
one = Repo(tmp_path / "remotes" / "one")
|
|
28
|
+
one.write("a.txt", lines(3))
|
|
29
|
+
one.commit("Alice", "one: add")
|
|
30
|
+
two = Repo(tmp_path / "remotes" / "two.repo")
|
|
31
|
+
two.write("c.txt", lines(2))
|
|
32
|
+
two.commit("Alice", "two: add")
|
|
33
|
+
return one, two
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def work(tmp_path):
|
|
38
|
+
path = tmp_path / "work"
|
|
39
|
+
path.mkdir()
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(monkeypatch, capsys, work, *argv):
|
|
44
|
+
"""Run the command in *work*; returns (stdout, stderr)."""
|
|
45
|
+
monkeypatch.chdir(work)
|
|
46
|
+
monkeypatch.setattr(
|
|
47
|
+
sys, "argv", ["gitlogstats", *argv, "-s", "01/01/2024"] + ["-e", "12/31/2024"]
|
|
48
|
+
)
|
|
49
|
+
main()
|
|
50
|
+
captured = capsys.readouterr()
|
|
51
|
+
return captured.out, captured.err
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestCommand:
|
|
55
|
+
def test_clones_are_kept_without_the_flag(self, remotes, work, monkeypatch, capsys):
|
|
56
|
+
run(monkeypatch, capsys, work, "-r", remotes[0].path)
|
|
57
|
+
assert (work / "repos" / "one" / ".git").is_dir()
|
|
58
|
+
|
|
59
|
+
def test_clobber_prints_the_same_results_and_removes_everything(
|
|
60
|
+
self, remotes, work, monkeypatch, capsys
|
|
61
|
+
):
|
|
62
|
+
kept, _ = run(monkeypatch, capsys, work, "-r", remotes[0].path, "-f", "json")
|
|
63
|
+
assert (work / "repos").exists()
|
|
64
|
+
# a fresh directory so that the second run clones again
|
|
65
|
+
other = work.parent / "work2"
|
|
66
|
+
other.mkdir()
|
|
67
|
+
out, err = run(
|
|
68
|
+
monkeypatch, capsys, other, "-r", remotes[0].path, "-f", "json", "--clobber"
|
|
69
|
+
)
|
|
70
|
+
assert out == kept and json.loads(out)[0]["commits"] == 1
|
|
71
|
+
assert err == ""
|
|
72
|
+
assert os.listdir(other) == [] # repos/ itself is gone
|
|
73
|
+
|
|
74
|
+
def test_other_clones_and_repos_dir_are_kept(
|
|
75
|
+
self, remotes, work, monkeypatch, capsys
|
|
76
|
+
):
|
|
77
|
+
run(monkeypatch, capsys, work, "-r", remotes[1].path)
|
|
78
|
+
out, _ = run(monkeypatch, capsys, work, "-r", remotes[0].path, "--clobber")
|
|
79
|
+
assert "Alice,one," in out
|
|
80
|
+
assert sorted(os.listdir(work / "repos")) == ["two.repo"]
|
|
81
|
+
|
|
82
|
+
def test_repofile_with_two_repos_and_a_duplicate(
|
|
83
|
+
self, remotes, work, monkeypatch, capsys
|
|
84
|
+
):
|
|
85
|
+
repofile = work.parent / "list.txt"
|
|
86
|
+
repofile.write_text(
|
|
87
|
+
f"{remotes[0].path}\n{remotes[1].path}\n{remotes[0].path}\n"
|
|
88
|
+
)
|
|
89
|
+
out, _ = run(monkeypatch, capsys, work, "-rf", str(repofile), "--clobber")
|
|
90
|
+
assert "Alice,one," in out and "Alice,two.repo," in out
|
|
91
|
+
assert os.listdir(work) == []
|
|
92
|
+
|
|
93
|
+
def test_failure_before_output_deletes_nothing(
|
|
94
|
+
self, remotes, work, monkeypatch, capsys
|
|
95
|
+
):
|
|
96
|
+
repofile = work.parent / "list.txt"
|
|
97
|
+
repofile.write_text(f"{remotes[0].path}\n{work.parent / 'missing'}\n")
|
|
98
|
+
with pytest.raises(Exception):
|
|
99
|
+
run(monkeypatch, capsys, work, "-rf", str(repofile), "--clobber")
|
|
100
|
+
assert (work / "repos" / "one" / ".git").is_dir()
|
|
101
|
+
assert capsys.readouterr().out == ""
|
|
102
|
+
|
|
103
|
+
def test_verbose_lists_deleted_paths_on_stderr_only(
|
|
104
|
+
self, remotes, work, monkeypatch, capsys
|
|
105
|
+
):
|
|
106
|
+
quiet, _ = run(monkeypatch, capsys, work, "-r", remotes[0].path, "--clobber")
|
|
107
|
+
out, err = run(
|
|
108
|
+
monkeypatch, capsys, work, "-r", remotes[0].path, "--clobber", "-v"
|
|
109
|
+
)
|
|
110
|
+
assert out == quiet
|
|
111
|
+
repos = os.path.realpath(work / "repos")
|
|
112
|
+
assert f"gitlogstats: deleted {os.path.join(repos, 'one')}" in err
|
|
113
|
+
assert f"gitlogstats: deleted {repos}\n" in err
|
|
114
|
+
assert "deleted" not in out
|
|
115
|
+
|
|
116
|
+
def test_clobber_with_github(self, remotes, tmp_path, monkeypatch, capsys):
|
|
117
|
+
github_world(tmp_path, monkeypatch, remotes[0], prs=[], contributors=[])
|
|
118
|
+
out, _ = run_github(
|
|
119
|
+
monkeypatch,
|
|
120
|
+
capsys,
|
|
121
|
+
tmp_path,
|
|
122
|
+
"-r",
|
|
123
|
+
GITHUB_URL,
|
|
124
|
+
"--github",
|
|
125
|
+
*DATES,
|
|
126
|
+
"--clobber",
|
|
127
|
+
)
|
|
128
|
+
assert "Alice" in out
|
|
129
|
+
assert not (tmp_path / "work" / "repos").exists()
|
|
130
|
+
|
|
131
|
+
def test_nothing_to_delete_when_no_repositories(self, work, monkeypatch, capsys):
|
|
132
|
+
repofile = work.parent / "empty.txt"
|
|
133
|
+
repofile.write_text("# nothing\n")
|
|
134
|
+
out, _ = run(monkeypatch, capsys, work, "-rf", str(repofile), "--clobber")
|
|
135
|
+
assert out == "" and os.listdir(work) == []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestRemoveClones:
|
|
139
|
+
def test_read_only_files_are_deleted(self, tmp_path):
|
|
140
|
+
repos = tmp_path / "repos"
|
|
141
|
+
clone = repos / "x"
|
|
142
|
+
(clone / "objects").mkdir(parents=True)
|
|
143
|
+
for name in ("a", "objects/pack"):
|
|
144
|
+
(clone / name).write_text("data")
|
|
145
|
+
os.chmod(clone / name, stat.S_IREAD)
|
|
146
|
+
os.chmod(clone / "objects", stat.S_IREAD | stat.S_IEXEC)
|
|
147
|
+
remove_clones(str(repos), [str(clone)])
|
|
148
|
+
assert not repos.exists()
|
|
149
|
+
|
|
150
|
+
def test_symlink_is_unlinked_and_target_untouched(self, tmp_path):
|
|
151
|
+
target = tmp_path / "precious"
|
|
152
|
+
target.mkdir()
|
|
153
|
+
(target / "keep.txt").write_text("keep")
|
|
154
|
+
repos = tmp_path / "repos"
|
|
155
|
+
repos.mkdir()
|
|
156
|
+
os.symlink(target, repos / "link")
|
|
157
|
+
remove_clones(str(repos), [str(repos / "link")])
|
|
158
|
+
assert not repos.exists()
|
|
159
|
+
assert (target / "keep.txt").read_text() == "keep"
|
|
160
|
+
|
|
161
|
+
def test_paths_outside_repos_are_skipped(self, tmp_path, capsys):
|
|
162
|
+
repos = tmp_path / "repos"
|
|
163
|
+
repos.mkdir()
|
|
164
|
+
outside = tmp_path / "outside"
|
|
165
|
+
outside.mkdir()
|
|
166
|
+
nested = repos / "a" / "b"
|
|
167
|
+
nested.mkdir(parents=True)
|
|
168
|
+
for path in (outside, repos / ".." / "outside", nested, repos):
|
|
169
|
+
remove_clones(str(repos), [str(path)], verbose=True)
|
|
170
|
+
assert outside.is_dir() and nested.is_dir() and repos.is_dir()
|
|
171
|
+
assert capsys.readouterr().err.count("not deleting") == 4
|
|
172
|
+
|
|
173
|
+
def test_skipped_paths_are_silent_without_verbose(self, tmp_path, capsys):
|
|
174
|
+
repos = tmp_path / "repos"
|
|
175
|
+
repos.mkdir()
|
|
176
|
+
outside = tmp_path / "outside"
|
|
177
|
+
outside.mkdir()
|
|
178
|
+
remove_clones(str(repos), [str(outside)])
|
|
179
|
+
assert outside.is_dir() and capsys.readouterr().err == ""
|
|
180
|
+
|
|
181
|
+
def test_a_file_in_place_of_a_clone_is_removed(self, tmp_path):
|
|
182
|
+
repos = tmp_path / "repos"
|
|
183
|
+
repos.mkdir()
|
|
184
|
+
(repos / "f").write_text("x")
|
|
185
|
+
remove_clones(str(repos), [str(repos / "f")])
|
|
186
|
+
assert not repos.exists()
|
|
187
|
+
|
|
188
|
+
def test_deletion_error_is_reported_and_the_rest_continues(
|
|
189
|
+
self, tmp_path, monkeypatch, capsys
|
|
190
|
+
):
|
|
191
|
+
repos = tmp_path / "repos"
|
|
192
|
+
for name in ("bad", "good"):
|
|
193
|
+
(repos / name).mkdir(parents=True)
|
|
194
|
+
|
|
195
|
+
real = clones._delete
|
|
196
|
+
|
|
197
|
+
def flaky(path):
|
|
198
|
+
if path.endswith("bad"):
|
|
199
|
+
raise PermissionError(13, "Permission denied")
|
|
200
|
+
real(path)
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr(clones, "_delete", flaky)
|
|
203
|
+
remove_clones(str(repos), [str(repos / "bad"), str(repos / "good")])
|
|
204
|
+
err = capsys.readouterr().err
|
|
205
|
+
assert (
|
|
206
|
+
f"gitlogstats: could not delete {repos / 'bad'}: Permission denied" in err
|
|
207
|
+
)
|
|
208
|
+
assert os.listdir(repos) == ["bad"]
|
|
209
|
+
|
|
210
|
+
@pytest.mark.skipif(
|
|
211
|
+
sys.platform == "win32" or os.geteuid() == 0,
|
|
212
|
+
reason="needs POSIX permissions that apply to the current user",
|
|
213
|
+
)
|
|
214
|
+
def test_unreadable_folder_is_reported_and_the_rest_continues(
|
|
215
|
+
self, tmp_path, capsys
|
|
216
|
+
):
|
|
217
|
+
# a folder that cannot be opened makes rmtree call the handler with
|
|
218
|
+
# os.open; retrying that must not crash after the results are printed
|
|
219
|
+
repos = tmp_path / "repos"
|
|
220
|
+
locked = repos / "one" / "notes" / "private"
|
|
221
|
+
locked.mkdir(parents=True)
|
|
222
|
+
(locked / "a.txt").write_text("x")
|
|
223
|
+
(repos / "two").mkdir()
|
|
224
|
+
locked.chmod(0)
|
|
225
|
+
try:
|
|
226
|
+
remove_clones(str(repos), [str(repos / "one"), str(repos / "two")])
|
|
227
|
+
finally:
|
|
228
|
+
locked.chmod(stat.S_IRWXU)
|
|
229
|
+
err = capsys.readouterr().err
|
|
230
|
+
assert f"gitlogstats: could not delete {repos / 'one'}" in err
|
|
231
|
+
assert not (repos / "two").exists()
|
|
232
|
+
|
|
233
|
+
def test_other_errors_while_deleting_are_not_retried(self, tmp_path):
|
|
234
|
+
with pytest.raises(FileNotFoundError):
|
|
235
|
+
clones._make_writable_and_retry(
|
|
236
|
+
os.remove, str(tmp_path / "gone"), (None, FileNotFoundError(), None)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def test_retry_gives_up_when_chmod_is_impossible(self, tmp_path):
|
|
240
|
+
# the paths do not exist, so chmod fails quietly and the retry raises
|
|
241
|
+
with pytest.raises(FileNotFoundError):
|
|
242
|
+
clones._make_writable_and_retry(
|
|
243
|
+
os.remove,
|
|
244
|
+
str(tmp_path / "no" / "gone"),
|
|
245
|
+
(None, PermissionError(), None),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def test_a_clone_that_is_already_gone_is_not_an_error(self, tmp_path, capsys):
|
|
249
|
+
repos = tmp_path / "repos"
|
|
250
|
+
repos.mkdir()
|
|
251
|
+
remove_clones(str(repos), [str(repos / "gone")], verbose=True)
|
|
252
|
+
assert capsys.readouterr().err == f"gitlogstats: deleted {repos}\n"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|