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.
Files changed (50) hide show
  1. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/PKG-INFO +28 -13
  2. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/README.md +27 -12
  3. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/docs/SPEC.md +7 -1
  4. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/pyproject.toml +1 -1
  5. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/manifest.yaml +17 -0
  6. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/__main__.py +20 -0
  7. gitlogstats-1.5.0/src/gitlogstats/clones.py +85 -0
  8. gitlogstats-1.5.0/tests/test_clobber.py +252 -0
  9. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/CLAUDE.md +0 -0
  10. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/agents/developer-agent.md +0 -0
  11. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/agents/spec-reviewer.md +0 -0
  12. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/format-edited.sh +0 -0
  13. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/guard-paths.sh +0 -0
  14. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/guard-paths.test.mjs +0 -0
  15. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/hooks/verify.sh +0 -0
  16. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/settings.json +0 -0
  17. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
  18. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.claude/skills/stale-check/SKILL.md +0 -0
  19. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.github/workflows/build.yaml +0 -0
  20. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.gitignore +0 -0
  21. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/.prettierrc.json +0 -0
  22. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/LICENSE +0 -0
  23. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/Pipfile +0 -0
  24. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/Pipfile.lock +0 -0
  25. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/package-lock.json +0 -0
  26. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/package.json +0 -0
  27. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/repos.txt +0 -0
  28. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/requirements.txt +0 -0
  29. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/config.mjs +0 -0
  30. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/derive.mjs +0 -0
  31. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/derive.test.mjs +0 -0
  32. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/scripts/board/sync.mjs +0 -0
  33. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/__init__.py +0 -0
  34. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/git_logs_parser.py +0 -0
  35. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/github.py +0 -0
  36. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/github_stats.py +0 -0
  37. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/src/gitlogstats/identity.py +0 -0
  38. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fake_gh.py +0 -0
  39. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/commit_linked.json +0 -0
  40. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/commit_unlinked.json +0 -0
  41. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/contributors.json +0 -0
  42. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/contributors_page2.json +0 -0
  43. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/pulls_page1.json +0 -0
  44. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/pulls_page2.json +0 -0
  45. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/user_full.json +0 -0
  46. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/fixtures/github/user_private.json +0 -0
  47. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/test_git_logs_parser.py +0 -0
  48. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/test_github.py +0 -0
  49. {gitlogstats-1.4.0 → gitlogstats-1.5.0}/tests/test_identity.py +0 -0
  50. {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.4.0
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 and their commits made on GitHub's **website** (see [GitHub pull requests](#github-pull-requests)).
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
- ### GitHub pull requests
843
+ ### Cloned repositories
841
844
 
842
- Add `--github` to also count each contributor's pull requests, pushes and website commits. Nine columns are appended to every output format:
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; without `-b`, all pull requests count.
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 and no pull requests.
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... This is most likely due to different username settings for various git and GitHub clients.
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
- If a single developer has multiple usernames that all show the same statistics, then only count those stats once. Otherwise, if a single developer has multiple usernames that show different statistics, then add them together to come up with the total for that developer.
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 and their commits made on GitHub's **website** (see [GitHub pull requests](#github-pull-requests)).
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
- ### GitHub pull requests
146
+ ### Cloned repositories
144
147
 
145
- Add `--github` to also count each contributor's pull requests, pushes and website commits. Nine columns are appended to every output format:
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; without `-b`, all pull requests count.
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 and no pull requests.
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... This is most likely due to different username settings for various git and GitHub clients.
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
- If a single developer has multiple usernames that all show the same statistics, then only count those stats once. Otherwise, if a single developer has multiple usernames that show different statistics, then add them together to come up with the total for that developer.
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.4.0"
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