rework-rate 0.1.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.
- rework_rate-0.1.0/LICENSE +21 -0
- rework_rate-0.1.0/PKG-INFO +118 -0
- rework_rate-0.1.0/README.md +94 -0
- rework_rate-0.1.0/pyproject.toml +40 -0
- rework_rate-0.1.0/rework_rate/__init__.py +43 -0
- rework_rate-0.1.0/rework_rate/__main__.py +4 -0
- rework_rate-0.1.0/rework_rate/measure.py +390 -0
- rework_rate-0.1.0/rework_rate.egg-info/PKG-INFO +118 -0
- rework_rate-0.1.0/rework_rate.egg-info/SOURCES.txt +11 -0
- rework_rate-0.1.0/rework_rate.egg-info/dependency_links.txt +1 -0
- rework_rate-0.1.0/rework_rate.egg-info/entry_points.txt +2 -0
- rework_rate-0.1.0/rework_rate.egg-info/top_level.txt +1 -0
- rework_rate-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tetsuroh Hori
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rework-rate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure how much of your git history is rework. No config, no signup, no network.
|
|
5
|
+
Author-email: Tetsuroh Hori <43089161+Tetsurohhori@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Tetsurohhori/rework-rate
|
|
8
|
+
Project-URL: Repository, https://github.com/Tetsurohhori/rework-rate
|
|
9
|
+
Keywords: git,rework,churn,cli
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# rework-rate
|
|
26
|
+
|
|
27
|
+
Measure how much of **your** git history is rework. One command. No config. No signup.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
rework-rate
|
|
31
|
+
rework-rate 34
|
|
32
|
+
rework-rate 34 --minutes 20 --hourly 5000
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`--minutes` and `--hourly` have **no defaults**. Both must be given, or neither. The printed yen line repeats those inputs. They are yours, not a measurement.
|
|
36
|
+
|
|
37
|
+
## Definition (public, breakable)
|
|
38
|
+
|
|
39
|
+
A commit counts as **rework** when:
|
|
40
|
+
|
|
41
|
+
1. its subject starts with `fix` or `revert` (case-insensitive), **and**
|
|
42
|
+
2. it touches a file that **this same history** already touched less than **24 hours** earlier.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
rework = a fix touching a file you changed less than 24h earlier
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That is the whole definition. If you disagree with it, you can show a counterexample on a real repository and we will treat that as a successful break. **This definition is not the only valid one. A claim that it is "the right" definition is not part of the tool.**
|
|
49
|
+
|
|
50
|
+
## Limits of this definition
|
|
51
|
+
|
|
52
|
+
- Fixes that are **not** labeled `fix` / `revert` are not counted.
|
|
53
|
+
- Operators who **commit corrections** (instead of amending silently, squashing, or force-pushing) will score **higher**. The number rewards leaving a trail, not "worse work."
|
|
54
|
+
- **24 hours is an arbitrary threshold.** It is not a natural unit of software. A 25-hour fix of the same file is invisible to this tool. A 23-hour rename that happens to start with `fix` is visible.
|
|
55
|
+
- Merge commits, empty commits, and files whose names contain whitespace follow the same `git show --name-only` split the prototype used. That is a limitation, not a feature.
|
|
56
|
+
|
|
57
|
+
If you have a definition that is stricter, looser, or labeled differently, write it down and run it. A definition that cannot be broken is not a definition.
|
|
58
|
+
|
|
59
|
+
## When this tool cannot measure
|
|
60
|
+
|
|
61
|
+
If the window contains **no** commit whose subject starts with `fix` or `revert`, the tool prints `not measurable here` and exits 0. It does **not** print 0%. A repository that never labels fixes is outside the definition, not a healthy score.
|
|
62
|
+
|
|
63
|
+
If at least one such subject exists, the rate and the weekly table are printed. The published reference line is printed only when there are **5 or more** `fix`/`revert` subjects. Fewer than that is too thin to sit next to another person's number.
|
|
64
|
+
|
|
65
|
+
## Published reference (one operator, one repo)
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
window 2026-07-21 … 2026-08-30 (34 days)
|
|
69
|
+
commits 817
|
|
70
|
+
overall 181 / 816 = 22.2%
|
|
71
|
+
Cursor 170 / 593 = 28.7%
|
|
72
|
+
reference 28.7% (n=1 repository, 1 operator, 6 complete weeks)
|
|
73
|
+
reference r = +0.87 (n=6 complete weeks)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**The n of the rate is not 817 commits.** It is one repository and one operator. The n of r = +0.87 is six complete ISO weeks. This is a published run, not a benchmark. The tool prints `yours` next to `reference` and does not say which is better.
|
|
77
|
+
|
|
78
|
+
## Relation to change failure rate
|
|
79
|
+
|
|
80
|
+
This number is a **pre-merge cousin of change failure rate** (CFR), the DORA / Accelerate term for the share of production deployments that fail.
|
|
81
|
+
|
|
82
|
+
| | CFR | this tool |
|
|
83
|
+
|--|-----|-----------|
|
|
84
|
+
| Place | production | commit history |
|
|
85
|
+
| Event | a deploy that failed in production | a `fix`/`revert` that retouches a file seen in the last 24 hours |
|
|
86
|
+
|
|
87
|
+
They are not the same quantity. This tool is not part of the DORA research program, and it is not an AI-era stand-in for those four measures. The vocabulary is borrowed. The authority is not.
|
|
88
|
+
|
|
89
|
+
Weekly correlation is Pearson's r of weekly commit count vs weekly rework rate. It is printed only when **at least 3 complete ISO weeks** remain after dropping incomplete weeks at **both ends** of the window. Two weeks can always produce r = ±1.00. We do not print that number.
|
|
90
|
+
|
|
91
|
+
An ISO week is **incomplete** when:
|
|
92
|
+
|
|
93
|
+
- it has not finished yet (the following Monday 00:00 UTC is still after the window end), **or**
|
|
94
|
+
- the window opens two or more UTC days after that week's Monday.
|
|
95
|
+
|
|
96
|
+
The first case is why a Monday run and a Sunday run used to disagree: one in-progress week (2 commits, 0% rework) moved r by 0.22. That week is still listed in the table, marked `(partial)`, and is **not** fed into r. The second case is why `N days ago` landing on a Tuesday does not, by itself, drop the first week. A two-day-or-more cut does.
|
|
97
|
+
|
|
98
|
+
**Display:** every ISO week that has commits in the window appears in the weekly table. Incomplete weeks are marked `(partial)`. Exclusion applies to the correlation only.
|
|
99
|
+
|
|
100
|
+
## What this tool does not do
|
|
101
|
+
|
|
102
|
+
- It does not send telemetry, usage data, crash reports, or version-check requests.
|
|
103
|
+
- It does not import `urllib`, `requests`, `http`, or `socket`.
|
|
104
|
+
- It does not write to your repository. The only git verbs it runs are `log`, `show`, and `rev-parse`.
|
|
105
|
+
- It does not create temporary files.
|
|
106
|
+
- It does not fetch, checkout, or change branches.
|
|
107
|
+
|
|
108
|
+
If it is not a git repository, or the window has no commits, it exits with a clear error.
|
|
109
|
+
|
|
110
|
+
## Install (local)
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
pip install -e ./packaging
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# rework-rate
|
|
2
|
+
|
|
3
|
+
Measure how much of **your** git history is rework. One command. No config. No signup.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
rework-rate
|
|
7
|
+
rework-rate 34
|
|
8
|
+
rework-rate 34 --minutes 20 --hourly 5000
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`--minutes` and `--hourly` have **no defaults**. Both must be given, or neither. The printed yen line repeats those inputs. They are yours, not a measurement.
|
|
12
|
+
|
|
13
|
+
## Definition (public, breakable)
|
|
14
|
+
|
|
15
|
+
A commit counts as **rework** when:
|
|
16
|
+
|
|
17
|
+
1. its subject starts with `fix` or `revert` (case-insensitive), **and**
|
|
18
|
+
2. it touches a file that **this same history** already touched less than **24 hours** earlier.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
rework = a fix touching a file you changed less than 24h earlier
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That is the whole definition. If you disagree with it, you can show a counterexample on a real repository and we will treat that as a successful break. **This definition is not the only valid one. A claim that it is "the right" definition is not part of the tool.**
|
|
25
|
+
|
|
26
|
+
## Limits of this definition
|
|
27
|
+
|
|
28
|
+
- Fixes that are **not** labeled `fix` / `revert` are not counted.
|
|
29
|
+
- Operators who **commit corrections** (instead of amending silently, squashing, or force-pushing) will score **higher**. The number rewards leaving a trail, not "worse work."
|
|
30
|
+
- **24 hours is an arbitrary threshold.** It is not a natural unit of software. A 25-hour fix of the same file is invisible to this tool. A 23-hour rename that happens to start with `fix` is visible.
|
|
31
|
+
- Merge commits, empty commits, and files whose names contain whitespace follow the same `git show --name-only` split the prototype used. That is a limitation, not a feature.
|
|
32
|
+
|
|
33
|
+
If you have a definition that is stricter, looser, or labeled differently, write it down and run it. A definition that cannot be broken is not a definition.
|
|
34
|
+
|
|
35
|
+
## When this tool cannot measure
|
|
36
|
+
|
|
37
|
+
If the window contains **no** commit whose subject starts with `fix` or `revert`, the tool prints `not measurable here` and exits 0. It does **not** print 0%. A repository that never labels fixes is outside the definition, not a healthy score.
|
|
38
|
+
|
|
39
|
+
If at least one such subject exists, the rate and the weekly table are printed. The published reference line is printed only when there are **5 or more** `fix`/`revert` subjects. Fewer than that is too thin to sit next to another person's number.
|
|
40
|
+
|
|
41
|
+
## Published reference (one operator, one repo)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
window 2026-07-21 … 2026-08-30 (34 days)
|
|
45
|
+
commits 817
|
|
46
|
+
overall 181 / 816 = 22.2%
|
|
47
|
+
Cursor 170 / 593 = 28.7%
|
|
48
|
+
reference 28.7% (n=1 repository, 1 operator, 6 complete weeks)
|
|
49
|
+
reference r = +0.87 (n=6 complete weeks)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**The n of the rate is not 817 commits.** It is one repository and one operator. The n of r = +0.87 is six complete ISO weeks. This is a published run, not a benchmark. The tool prints `yours` next to `reference` and does not say which is better.
|
|
53
|
+
|
|
54
|
+
## Relation to change failure rate
|
|
55
|
+
|
|
56
|
+
This number is a **pre-merge cousin of change failure rate** (CFR), the DORA / Accelerate term for the share of production deployments that fail.
|
|
57
|
+
|
|
58
|
+
| | CFR | this tool |
|
|
59
|
+
|--|-----|-----------|
|
|
60
|
+
| Place | production | commit history |
|
|
61
|
+
| Event | a deploy that failed in production | a `fix`/`revert` that retouches a file seen in the last 24 hours |
|
|
62
|
+
|
|
63
|
+
They are not the same quantity. This tool is not part of the DORA research program, and it is not an AI-era stand-in for those four measures. The vocabulary is borrowed. The authority is not.
|
|
64
|
+
|
|
65
|
+
Weekly correlation is Pearson's r of weekly commit count vs weekly rework rate. It is printed only when **at least 3 complete ISO weeks** remain after dropping incomplete weeks at **both ends** of the window. Two weeks can always produce r = ±1.00. We do not print that number.
|
|
66
|
+
|
|
67
|
+
An ISO week is **incomplete** when:
|
|
68
|
+
|
|
69
|
+
- it has not finished yet (the following Monday 00:00 UTC is still after the window end), **or**
|
|
70
|
+
- the window opens two or more UTC days after that week's Monday.
|
|
71
|
+
|
|
72
|
+
The first case is why a Monday run and a Sunday run used to disagree: one in-progress week (2 commits, 0% rework) moved r by 0.22. That week is still listed in the table, marked `(partial)`, and is **not** fed into r. The second case is why `N days ago` landing on a Tuesday does not, by itself, drop the first week. A two-day-or-more cut does.
|
|
73
|
+
|
|
74
|
+
**Display:** every ISO week that has commits in the window appears in the weekly table. Incomplete weeks are marked `(partial)`. Exclusion applies to the correlation only.
|
|
75
|
+
|
|
76
|
+
## What this tool does not do
|
|
77
|
+
|
|
78
|
+
- It does not send telemetry, usage data, crash reports, or version-check requests.
|
|
79
|
+
- It does not import `urllib`, `requests`, `http`, or `socket`.
|
|
80
|
+
- It does not write to your repository. The only git verbs it runs are `log`, `show`, and `rev-parse`.
|
|
81
|
+
- It does not create temporary files.
|
|
82
|
+
- It does not fetch, checkout, or change branches.
|
|
83
|
+
|
|
84
|
+
If it is not a git repository, or the window has no commits, it exits with a clear error.
|
|
85
|
+
|
|
86
|
+
## Install (local)
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
pip install -e ./packaging
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rework-rate"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Measure how much of your git history is rework. No config, no signup, no network."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Tetsuroh Hori", email = "43089161+Tetsurohhori@users.noreply.github.com" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["git", "rework", "churn", "cli"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/Tetsurohhori/rework-rate"
|
|
33
|
+
Repository = "https://github.com/Tetsurohhori/rework-rate"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
rework-rate = "rework_rate.measure:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
40
|
+
include = ["rework_rate*"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""rework-rate — local CLI. No network. Read-only git."""
|
|
2
|
+
|
|
3
|
+
from rework_rate.measure import (
|
|
4
|
+
BASELINE_R,
|
|
5
|
+
BASELINE_RATE,
|
|
6
|
+
DEFAULT_DAYS,
|
|
7
|
+
LEADING_PARTIAL_SECONDS,
|
|
8
|
+
MIN_FIX_LIKE_FOR_REFERENCE,
|
|
9
|
+
MIN_WEEKS_FOR_CORRELATION,
|
|
10
|
+
REFERENCE_N,
|
|
11
|
+
REFERENCE_R,
|
|
12
|
+
REFERENCE_RATE,
|
|
13
|
+
REWORK_WINDOW_SECONDS,
|
|
14
|
+
CliOptions,
|
|
15
|
+
MeasureResult,
|
|
16
|
+
ReworkError,
|
|
17
|
+
WeekRow,
|
|
18
|
+
format_report,
|
|
19
|
+
measure,
|
|
20
|
+
parse_cli,
|
|
21
|
+
week_is_partial,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BASELINE_R",
|
|
26
|
+
"BASELINE_RATE",
|
|
27
|
+
"DEFAULT_DAYS",
|
|
28
|
+
"LEADING_PARTIAL_SECONDS",
|
|
29
|
+
"MIN_FIX_LIKE_FOR_REFERENCE",
|
|
30
|
+
"MIN_WEEKS_FOR_CORRELATION",
|
|
31
|
+
"REFERENCE_N",
|
|
32
|
+
"REFERENCE_R",
|
|
33
|
+
"REFERENCE_RATE",
|
|
34
|
+
"REWORK_WINDOW_SECONDS",
|
|
35
|
+
"CliOptions",
|
|
36
|
+
"MeasureResult",
|
|
37
|
+
"ReworkError",
|
|
38
|
+
"WeekRow",
|
|
39
|
+
"format_report",
|
|
40
|
+
"measure",
|
|
41
|
+
"parse_cli",
|
|
42
|
+
"week_is_partial",
|
|
43
|
+
]
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Measure how much of a git history is rework. Stdlib only. Read-only git."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import collections
|
|
8
|
+
import datetime
|
|
9
|
+
import statistics as st
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
DEFAULT_DAYS = 30
|
|
17
|
+
REWORK_WINDOW_SECONDS = 86400
|
|
18
|
+
MIN_WEEKS_FOR_CORRELATION = 3
|
|
19
|
+
LEADING_PARTIAL_SECONDS = 2 * 86400
|
|
20
|
+
MIN_FIX_LIKE_FOR_REFERENCE = 5
|
|
21
|
+
REFERENCE_RATE = 28.7
|
|
22
|
+
REFERENCE_R = 0.87
|
|
23
|
+
REFERENCE_N = "n=1 repository, 1 operator, 6 complete weeks"
|
|
24
|
+
REFERENCE_R_N = "n=6 complete weeks"
|
|
25
|
+
BASELINE_RATE = REFERENCE_RATE
|
|
26
|
+
BASELINE_R = REFERENCE_R
|
|
27
|
+
REWORK_PREFIXES = ("fix", "revert")
|
|
28
|
+
_ALLOWED_GIT = frozenset({"log", "show", "rev-parse"})
|
|
29
|
+
_FORBIDDEN_GIT = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"add",
|
|
32
|
+
"checkout",
|
|
33
|
+
"commit",
|
|
34
|
+
"fetch",
|
|
35
|
+
"merge",
|
|
36
|
+
"pull",
|
|
37
|
+
"push",
|
|
38
|
+
"rebase",
|
|
39
|
+
"reset",
|
|
40
|
+
"rm",
|
|
41
|
+
"stash",
|
|
42
|
+
"switch",
|
|
43
|
+
"tag",
|
|
44
|
+
"worktree",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ReworkError(SystemExit):
|
|
50
|
+
"""Clear failure: not a git repo, or no commits in the window."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class WeekRow:
|
|
55
|
+
week: str
|
|
56
|
+
commits: int
|
|
57
|
+
rework: int
|
|
58
|
+
rate: float
|
|
59
|
+
partial: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class MeasureResult:
|
|
64
|
+
commits: int
|
|
65
|
+
rework: int
|
|
66
|
+
rate: float
|
|
67
|
+
weeks: tuple[WeekRow, ...]
|
|
68
|
+
correlation: float | None
|
|
69
|
+
days: int
|
|
70
|
+
fix_like: int
|
|
71
|
+
median_lag_seconds: float | None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class CliOptions:
|
|
76
|
+
days: int
|
|
77
|
+
minutes: float | None
|
|
78
|
+
hourly: float | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
82
|
+
if not args:
|
|
83
|
+
raise ReworkError("internal: empty git invocation")
|
|
84
|
+
verb = args[0]
|
|
85
|
+
if verb in _FORBIDDEN_GIT or verb not in _ALLOWED_GIT:
|
|
86
|
+
raise ReworkError(f"refusing git {verb}: read-only (log / show / rev-parse only)")
|
|
87
|
+
return subprocess.run(
|
|
88
|
+
["git", *args],
|
|
89
|
+
cwd=str(repo),
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
check=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _require_repo(repo: Path) -> None:
|
|
97
|
+
probe = _git(repo, "rev-parse", "--is-inside-work-tree")
|
|
98
|
+
if probe.returncode != 0 or probe.stdout.strip() != "true":
|
|
99
|
+
raise ReworkError("not a git repository")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _iso_from_unix(ts: int) -> str:
|
|
103
|
+
return datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _iso_week_bounds(week_label: str) -> tuple[int, int]:
|
|
107
|
+
year_s, week_s = week_label.split("-W", 1)
|
|
108
|
+
monday = datetime.date.fromisocalendar(int(year_s), int(week_s), 1)
|
|
109
|
+
start = datetime.datetime(monday.year, monday.month, monday.day, tzinfo=datetime.timezone.utc)
|
|
110
|
+
end = start + datetime.timedelta(days=7)
|
|
111
|
+
return int(start.timestamp()), int(end.timestamp())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def week_is_partial(week_label: str, window_start: int, window_end: int) -> bool:
|
|
115
|
+
"""Incomplete ISO week at either end of the measurement window.
|
|
116
|
+
|
|
117
|
+
Trailing: the week has not finished (next Monday 00:00 UTC is still after
|
|
118
|
+
window_end). Leading: the window opens two or more UTC days after that
|
|
119
|
+
week's Monday. A one-day cut from ``N days ago`` landing on Tuesday does
|
|
120
|
+
not drop the first week; a two-day-or-more cut does.
|
|
121
|
+
"""
|
|
122
|
+
week_start, week_end = _iso_week_bounds(week_label)
|
|
123
|
+
if week_end > window_end:
|
|
124
|
+
return True
|
|
125
|
+
if week_start < window_start and (window_start - week_start) >= LEADING_PARTIAL_SECONDS:
|
|
126
|
+
return True
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _log(repo: Path, window_start: int, window_end: int) -> list[tuple[str, int, str]]:
|
|
131
|
+
raw = _git(
|
|
132
|
+
repo,
|
|
133
|
+
"log",
|
|
134
|
+
f"--since={_iso_from_unix(window_start)}",
|
|
135
|
+
f"--until={_iso_from_unix(window_end)}",
|
|
136
|
+
"--format=%H|%ct|%s",
|
|
137
|
+
)
|
|
138
|
+
if raw.returncode != 0:
|
|
139
|
+
err = (raw.stderr or raw.stdout or "git log failed").strip()
|
|
140
|
+
raise ReworkError(err)
|
|
141
|
+
lines = [ln for ln in raw.stdout.splitlines() if ln.strip()]
|
|
142
|
+
if not lines:
|
|
143
|
+
raise ReworkError("no commits in window")
|
|
144
|
+
commits: list[tuple[str, int, str]] = []
|
|
145
|
+
for line in lines:
|
|
146
|
+
hid, ts, subject = line.split("|", 2)
|
|
147
|
+
commits.append((hid, int(ts), subject))
|
|
148
|
+
commits.sort(key=lambda row: row[1])
|
|
149
|
+
return commits
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _files(repo: Path, hid: str) -> list[str]:
|
|
153
|
+
shown = _git(repo, "show", "--name-only", "--format=", hid)
|
|
154
|
+
if shown.returncode != 0:
|
|
155
|
+
err = (shown.stderr or shown.stdout or f"git show failed for {hid}").strip()
|
|
156
|
+
raise ReworkError(err)
|
|
157
|
+
return [ln for ln in shown.stdout.split() if ln]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_rework_subject(subject: str) -> bool:
|
|
161
|
+
return subject.lower().startswith(REWORK_PREFIXES)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _pearson(xs: list[float], ys: list[float]) -> float | None:
|
|
165
|
+
if len(xs) < MIN_WEEKS_FOR_CORRELATION or len(xs) != len(ys):
|
|
166
|
+
return None
|
|
167
|
+
mx, my = st.mean(xs), st.mean(ys)
|
|
168
|
+
den = (sum((a - mx) ** 2 for a in xs) * sum((b - my) ** 2 for b in ys)) ** 0.5
|
|
169
|
+
if not den:
|
|
170
|
+
return None
|
|
171
|
+
return sum((a - mx) * (b - my) for a, b in zip(xs, ys)) / den
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def measure(
|
|
175
|
+
days: int = DEFAULT_DAYS,
|
|
176
|
+
repo: Path | None = None,
|
|
177
|
+
*,
|
|
178
|
+
now: int | None = None,
|
|
179
|
+
) -> MeasureResult:
|
|
180
|
+
if days < 1:
|
|
181
|
+
raise ReworkError("days must be >= 1")
|
|
182
|
+
root = (repo or Path.cwd()).resolve()
|
|
183
|
+
_require_repo(root)
|
|
184
|
+
window_end = int(now if now is not None else time.time())
|
|
185
|
+
window_start = window_end - days * 86400
|
|
186
|
+
commits = _log(root, window_start, window_end)
|
|
187
|
+
last: dict[str, int] = {}
|
|
188
|
+
per: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
|
|
189
|
+
total = 0
|
|
190
|
+
rework = 0
|
|
191
|
+
fix_like = 0
|
|
192
|
+
lags: list[float] = []
|
|
193
|
+
for hid, ts, subject in commits:
|
|
194
|
+
files = _files(root, hid)
|
|
195
|
+
week = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).strftime("%G-W%V")
|
|
196
|
+
per[week][0] += 1
|
|
197
|
+
total += 1
|
|
198
|
+
if _is_rework_subject(subject):
|
|
199
|
+
fix_like += 1
|
|
200
|
+
prior = [ts - last[name] for name in files if name in last]
|
|
201
|
+
if prior and min(prior) < REWORK_WINDOW_SECONDS:
|
|
202
|
+
gap = float(min(prior))
|
|
203
|
+
per[week][1] += 1
|
|
204
|
+
rework += 1
|
|
205
|
+
lags.append(gap)
|
|
206
|
+
for name in files:
|
|
207
|
+
last[name] = ts
|
|
208
|
+
rate = (rework / total * 100.0) if total else 0.0
|
|
209
|
+
weeks: list[WeekRow] = []
|
|
210
|
+
ns: list[float] = []
|
|
211
|
+
rs: list[float] = []
|
|
212
|
+
for week in sorted(per):
|
|
213
|
+
n, r = per[week]
|
|
214
|
+
p = r / n * 100.0
|
|
215
|
+
partial = week_is_partial(week, window_start, window_end)
|
|
216
|
+
weeks.append(WeekRow(week=week, commits=n, rework=r, rate=p, partial=partial))
|
|
217
|
+
if not partial:
|
|
218
|
+
ns.append(float(n))
|
|
219
|
+
rs.append(p)
|
|
220
|
+
return MeasureResult(
|
|
221
|
+
commits=total,
|
|
222
|
+
rework=rework,
|
|
223
|
+
rate=rate,
|
|
224
|
+
weeks=tuple(weeks),
|
|
225
|
+
correlation=_pearson(ns, rs),
|
|
226
|
+
days=days,
|
|
227
|
+
fix_like=fix_like,
|
|
228
|
+
median_lag_seconds=st.median(lags) if lags else None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _bold(text: str) -> str:
|
|
233
|
+
if sys.stdout.isatty():
|
|
234
|
+
return f"\033[1m{text}\033[0m"
|
|
235
|
+
return text
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _format_lag(seconds: float) -> str:
|
|
239
|
+
total = max(0, int(round(seconds)))
|
|
240
|
+
hours, rem = divmod(total, 3600)
|
|
241
|
+
minutes, secs = divmod(rem, 60)
|
|
242
|
+
if hours:
|
|
243
|
+
return f"{hours}h {minutes}m"
|
|
244
|
+
if minutes:
|
|
245
|
+
return f"{minutes}m"
|
|
246
|
+
return f"{secs}s"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _plain_number(value: float) -> str:
|
|
250
|
+
if value == int(value):
|
|
251
|
+
return str(int(value))
|
|
252
|
+
return f"{value:g}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _yen(amount: float) -> str:
|
|
256
|
+
return f"¥{amount:,.0f}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def format_unmeasurable(result: MeasureResult) -> str:
|
|
260
|
+
return (
|
|
261
|
+
"\n"
|
|
262
|
+
" not measurable here\n"
|
|
263
|
+
"\n"
|
|
264
|
+
f" {result.commits} commits in the last {result.days} days, "
|
|
265
|
+
"0 of them with a fix/revert subject line.\n"
|
|
266
|
+
" This tool infers rework from commit subjects. Your repository does not use\n"
|
|
267
|
+
" that convention, so it has nothing to count — this is not a score of 0%.\n"
|
|
268
|
+
"\n"
|
|
269
|
+
" It will work on a repository where fixes are labelled (Conventional Commits,\n"
|
|
270
|
+
' or any subject starting with "fix" or "revert").\n'
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def format_report(
|
|
275
|
+
result: MeasureResult,
|
|
276
|
+
minutes: float | None = None,
|
|
277
|
+
hourly: float | None = None,
|
|
278
|
+
) -> str:
|
|
279
|
+
if result.fix_like == 0:
|
|
280
|
+
return format_unmeasurable(result)
|
|
281
|
+
lines = [
|
|
282
|
+
"",
|
|
283
|
+
f" commits {result.commits} · rework {result.rework} · {_bold(f'rework rate {result.rate:.1f}%')}",
|
|
284
|
+
" (rework = a fix touching a file you changed less than 24h earlier)",
|
|
285
|
+
"",
|
|
286
|
+
" week commits rework rate",
|
|
287
|
+
]
|
|
288
|
+
for row in result.weeks:
|
|
289
|
+
bar = "#" * int(row.rate / 2)
|
|
290
|
+
mark = " (partial)" if row.partial else ""
|
|
291
|
+
lines.append(
|
|
292
|
+
f" {row.week} {row.commits:7d} {row.rework:6d} {row.rate:5.1f}% {bar}{mark}"
|
|
293
|
+
)
|
|
294
|
+
if result.correlation is not None:
|
|
295
|
+
lines.append("")
|
|
296
|
+
lines.append(
|
|
297
|
+
f" throughput vs rework r = {result.correlation:+.2f} (complete ISO weeks only)"
|
|
298
|
+
)
|
|
299
|
+
if result.correlation > 0.5:
|
|
300
|
+
lines.append(" → the weeks you shipped most are the weeks you redid most")
|
|
301
|
+
if result.median_lag_seconds is not None:
|
|
302
|
+
lines.append("")
|
|
303
|
+
lines.append(
|
|
304
|
+
f" median {_format_lag(result.median_lag_seconds)} between the original commit and its fix"
|
|
305
|
+
)
|
|
306
|
+
lines.append(" (elapsed time in git, not effort)")
|
|
307
|
+
if minutes is not None and hourly is not None:
|
|
308
|
+
yen = result.rework * minutes / 60.0 * hourly
|
|
309
|
+
lines.append("")
|
|
310
|
+
lines.append(
|
|
311
|
+
f" {result.rework} rework × {_plain_number(minutes)} min (yours) × "
|
|
312
|
+
f"{_yen(hourly)}/h (yours) = {_yen(yen)}"
|
|
313
|
+
)
|
|
314
|
+
if result.fix_like >= MIN_FIX_LIKE_FOR_REFERENCE:
|
|
315
|
+
lines.append("")
|
|
316
|
+
lines.append(
|
|
317
|
+
f" yours: {result.rate:.1f}% · "
|
|
318
|
+
f"reference: {REFERENCE_RATE}% ({REFERENCE_N})"
|
|
319
|
+
)
|
|
320
|
+
lines.append(f" reference r = {REFERENCE_R:+.2f} ({REFERENCE_R_N})")
|
|
321
|
+
lines.append("")
|
|
322
|
+
return "\n".join(lines)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _cli_parser() -> argparse.ArgumentParser:
|
|
326
|
+
parser = argparse.ArgumentParser(
|
|
327
|
+
prog="rework-rate",
|
|
328
|
+
description="Measure rework in this git repo. Read-only. No network.",
|
|
329
|
+
)
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
"days",
|
|
332
|
+
nargs="?",
|
|
333
|
+
type=int,
|
|
334
|
+
default=DEFAULT_DAYS,
|
|
335
|
+
help="Look-back window in days (default: 30)",
|
|
336
|
+
)
|
|
337
|
+
parser.add_argument(
|
|
338
|
+
"--minutes",
|
|
339
|
+
type=float,
|
|
340
|
+
default=None,
|
|
341
|
+
help="Minutes per rework you assign. No default. Requires --hourly.",
|
|
342
|
+
)
|
|
343
|
+
parser.add_argument(
|
|
344
|
+
"--hourly",
|
|
345
|
+
type=float,
|
|
346
|
+
default=None,
|
|
347
|
+
help="Hourly rate you assign. No default. Requires --minutes.",
|
|
348
|
+
)
|
|
349
|
+
return parser
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def parse_cli(argv: list[str]) -> CliOptions:
|
|
353
|
+
parser = _cli_parser()
|
|
354
|
+
try:
|
|
355
|
+
ns = parser.parse_args(argv)
|
|
356
|
+
except SystemExit as exc:
|
|
357
|
+
code = exc.code if isinstance(exc.code, int) else 1
|
|
358
|
+
if code == 0:
|
|
359
|
+
raise
|
|
360
|
+
raise ReworkError("usage: rework-rate [days] [--minutes MINUTES --hourly HOURLY]") from exc
|
|
361
|
+
if ns.days < 1:
|
|
362
|
+
raise ReworkError("days must be >= 1")
|
|
363
|
+
minutes = ns.minutes
|
|
364
|
+
hourly = ns.hourly
|
|
365
|
+
if (minutes is None) ^ (hourly is None):
|
|
366
|
+
raise ReworkError("--minutes and --hourly must be given together (no defaults)")
|
|
367
|
+
if minutes is not None and hourly is not None and (minutes <= 0 or hourly <= 0):
|
|
368
|
+
raise ReworkError("--minutes and --hourly must be positive")
|
|
369
|
+
return CliOptions(days=int(ns.days), minutes=minutes, hourly=hourly)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def parse_days(argv: list[str]) -> int:
|
|
373
|
+
return parse_cli(argv).days
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main(argv: list[str] | None = None) -> int:
|
|
377
|
+
try:
|
|
378
|
+
opts = parse_cli(sys.argv[1:] if argv is None else argv)
|
|
379
|
+
print(
|
|
380
|
+
format_report(measure(opts.days), minutes=opts.minutes, hourly=opts.hourly),
|
|
381
|
+
end="",
|
|
382
|
+
)
|
|
383
|
+
return 0
|
|
384
|
+
except ReworkError as exc:
|
|
385
|
+
print(str(exc) or "failed", file=sys.stderr)
|
|
386
|
+
return 1
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rework-rate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure how much of your git history is rework. No config, no signup, no network.
|
|
5
|
+
Author-email: Tetsuroh Hori <43089161+Tetsurohhori@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Tetsurohhori/rework-rate
|
|
8
|
+
Project-URL: Repository, https://github.com/Tetsurohhori/rework-rate
|
|
9
|
+
Keywords: git,rework,churn,cli
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# rework-rate
|
|
26
|
+
|
|
27
|
+
Measure how much of **your** git history is rework. One command. No config. No signup.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
rework-rate
|
|
31
|
+
rework-rate 34
|
|
32
|
+
rework-rate 34 --minutes 20 --hourly 5000
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`--minutes` and `--hourly` have **no defaults**. Both must be given, or neither. The printed yen line repeats those inputs. They are yours, not a measurement.
|
|
36
|
+
|
|
37
|
+
## Definition (public, breakable)
|
|
38
|
+
|
|
39
|
+
A commit counts as **rework** when:
|
|
40
|
+
|
|
41
|
+
1. its subject starts with `fix` or `revert` (case-insensitive), **and**
|
|
42
|
+
2. it touches a file that **this same history** already touched less than **24 hours** earlier.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
rework = a fix touching a file you changed less than 24h earlier
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That is the whole definition. If you disagree with it, you can show a counterexample on a real repository and we will treat that as a successful break. **This definition is not the only valid one. A claim that it is "the right" definition is not part of the tool.**
|
|
49
|
+
|
|
50
|
+
## Limits of this definition
|
|
51
|
+
|
|
52
|
+
- Fixes that are **not** labeled `fix` / `revert` are not counted.
|
|
53
|
+
- Operators who **commit corrections** (instead of amending silently, squashing, or force-pushing) will score **higher**. The number rewards leaving a trail, not "worse work."
|
|
54
|
+
- **24 hours is an arbitrary threshold.** It is not a natural unit of software. A 25-hour fix of the same file is invisible to this tool. A 23-hour rename that happens to start with `fix` is visible.
|
|
55
|
+
- Merge commits, empty commits, and files whose names contain whitespace follow the same `git show --name-only` split the prototype used. That is a limitation, not a feature.
|
|
56
|
+
|
|
57
|
+
If you have a definition that is stricter, looser, or labeled differently, write it down and run it. A definition that cannot be broken is not a definition.
|
|
58
|
+
|
|
59
|
+
## When this tool cannot measure
|
|
60
|
+
|
|
61
|
+
If the window contains **no** commit whose subject starts with `fix` or `revert`, the tool prints `not measurable here` and exits 0. It does **not** print 0%. A repository that never labels fixes is outside the definition, not a healthy score.
|
|
62
|
+
|
|
63
|
+
If at least one such subject exists, the rate and the weekly table are printed. The published reference line is printed only when there are **5 or more** `fix`/`revert` subjects. Fewer than that is too thin to sit next to another person's number.
|
|
64
|
+
|
|
65
|
+
## Published reference (one operator, one repo)
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
window 2026-07-21 … 2026-08-30 (34 days)
|
|
69
|
+
commits 817
|
|
70
|
+
overall 181 / 816 = 22.2%
|
|
71
|
+
Cursor 170 / 593 = 28.7%
|
|
72
|
+
reference 28.7% (n=1 repository, 1 operator, 6 complete weeks)
|
|
73
|
+
reference r = +0.87 (n=6 complete weeks)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**The n of the rate is not 817 commits.** It is one repository and one operator. The n of r = +0.87 is six complete ISO weeks. This is a published run, not a benchmark. The tool prints `yours` next to `reference` and does not say which is better.
|
|
77
|
+
|
|
78
|
+
## Relation to change failure rate
|
|
79
|
+
|
|
80
|
+
This number is a **pre-merge cousin of change failure rate** (CFR), the DORA / Accelerate term for the share of production deployments that fail.
|
|
81
|
+
|
|
82
|
+
| | CFR | this tool |
|
|
83
|
+
|--|-----|-----------|
|
|
84
|
+
| Place | production | commit history |
|
|
85
|
+
| Event | a deploy that failed in production | a `fix`/`revert` that retouches a file seen in the last 24 hours |
|
|
86
|
+
|
|
87
|
+
They are not the same quantity. This tool is not part of the DORA research program, and it is not an AI-era stand-in for those four measures. The vocabulary is borrowed. The authority is not.
|
|
88
|
+
|
|
89
|
+
Weekly correlation is Pearson's r of weekly commit count vs weekly rework rate. It is printed only when **at least 3 complete ISO weeks** remain after dropping incomplete weeks at **both ends** of the window. Two weeks can always produce r = ±1.00. We do not print that number.
|
|
90
|
+
|
|
91
|
+
An ISO week is **incomplete** when:
|
|
92
|
+
|
|
93
|
+
- it has not finished yet (the following Monday 00:00 UTC is still after the window end), **or**
|
|
94
|
+
- the window opens two or more UTC days after that week's Monday.
|
|
95
|
+
|
|
96
|
+
The first case is why a Monday run and a Sunday run used to disagree: one in-progress week (2 commits, 0% rework) moved r by 0.22. That week is still listed in the table, marked `(partial)`, and is **not** fed into r. The second case is why `N days ago` landing on a Tuesday does not, by itself, drop the first week. A two-day-or-more cut does.
|
|
97
|
+
|
|
98
|
+
**Display:** every ISO week that has commits in the window appears in the weekly table. Incomplete weeks are marked `(partial)`. Exclusion applies to the correlation only.
|
|
99
|
+
|
|
100
|
+
## What this tool does not do
|
|
101
|
+
|
|
102
|
+
- It does not send telemetry, usage data, crash reports, or version-check requests.
|
|
103
|
+
- It does not import `urllib`, `requests`, `http`, or `socket`.
|
|
104
|
+
- It does not write to your repository. The only git verbs it runs are `log`, `show`, and `rev-parse`.
|
|
105
|
+
- It does not create temporary files.
|
|
106
|
+
- It does not fetch, checkout, or change branches.
|
|
107
|
+
|
|
108
|
+
If it is not a git repository, or the window has no commits, it exits with a clear error.
|
|
109
|
+
|
|
110
|
+
## Install (local)
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
pip install -e ./packaging
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
rework_rate/__init__.py
|
|
5
|
+
rework_rate/__main__.py
|
|
6
|
+
rework_rate/measure.py
|
|
7
|
+
rework_rate.egg-info/PKG-INFO
|
|
8
|
+
rework_rate.egg-info/SOURCES.txt
|
|
9
|
+
rework_rate.egg-info/dependency_links.txt
|
|
10
|
+
rework_rate.egg-info/entry_points.txt
|
|
11
|
+
rework_rate.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rework_rate
|