gitlogstats 1.4.1__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.1 → gitlogstats-1.5.0}/PKG-INFO +15 -2
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/README.md +14 -1
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/docs/SPEC.md +7 -1
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/pyproject.toml +1 -1
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/scripts/board/manifest.yaml +17 -0
- {gitlogstats-1.4.1 → 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.1 → gitlogstats-1.5.0}/.claude/CLAUDE.md +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/agents/developer-agent.md +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/agents/spec-reviewer.md +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/hooks/format-edited.sh +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/hooks/guard-paths.sh +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/hooks/guard-paths.test.mjs +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/hooks/verify.sh +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/settings.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.claude/skills/stale-check/SKILL.md +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.github/workflows/build.yaml +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.gitignore +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/.prettierrc.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/LICENSE +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/Pipfile +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/Pipfile.lock +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/package-lock.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/package.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/repos.txt +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/requirements.txt +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/scripts/board/config.mjs +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/scripts/board/derive.mjs +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/scripts/board/derive.test.mjs +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/scripts/board/sync.mjs +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/src/gitlogstats/__init__.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/src/gitlogstats/git_logs_parser.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/src/gitlogstats/github.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/src/gitlogstats/github_stats.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/src/gitlogstats/identity.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fake_gh.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/commit_linked.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/commit_unlinked.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/contributors.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/contributors_page2.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/pulls_page1.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/pulls_page2.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/user_full.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/fixtures/github/user_private.json +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/test_git_logs_parser.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/test_github.py +0 -0
- {gitlogstats-1.4.1 → gitlogstats-1.5.0}/tests/test_identity.py +0 -0
- {gitlogstats-1.4.1 → 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
|
|
@@ -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,6 +840,16 @@ 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
|
|
|
843
|
+
### Cloned repositories
|
|
844
|
+
|
|
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
|
+
|
|
840
853
|
### GitHub statistics
|
|
841
854
|
|
|
842
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:
|
|
@@ -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,6 +143,16 @@ 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
|
|
|
146
|
+
### Cloned repositories
|
|
147
|
+
|
|
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
|
+
|
|
143
156
|
### GitHub statistics
|
|
144
157
|
|
|
145
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:
|
|
@@ -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
|