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.
@@ -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,4 @@
1
+ from rework_rate.measure import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ rework-rate = rework_rate.measure:main
@@ -0,0 +1 @@
1
+ rework_rate
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+