commit-rewriter 0.1__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,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: commit-rewriter
3
+ Version: 0.1
4
+ Summary: Local web app for editing local commit messages to a Git repository
5
+ Author: Simon Willison
6
+ Project-URL: Homepage, https://github.com/simonw/commit-rewriter
7
+ Project-URL: Changelog, https://github.com/simonw/commit-rewriter/releases
8
+ Project-URL: Issues, https://github.com/simonw/commit-rewriter/issues
9
+ Project-URL: CI, https://github.com/simonw/commit-rewriter/actions
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: starlette<1,>=0.47
13
+ Requires-Dist: uvicorn<1,>=0.35
14
+
15
+ # commit-rewriter
16
+
17
+ [![PyPI](https://img.shields.io/pypi/v/commit-rewriter.svg)](https://pypi.org/project/commit-rewriter/)
18
+ [![Changelog](https://img.shields.io/github/v/release/simonw/commit-rewriter?include_prereleases&label=changelog)](https://github.com/simonw/commit-rewriter/releases)
19
+ [![Tests](https://github.com/simonw/commit-rewriter/actions/workflows/test.yml/badge.svg)](https://github.com/simonw/commit-rewriter/actions/workflows/test.yml)
20
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/commit-rewriter/blob/master/LICENSE)
21
+
22
+ Local web app for editing local commit messages to a Git repository.
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ uvx commit-rewriter /path/to/repository
28
+ ```
29
+ Or omit the path if the repository is your current working directory.
30
+
31
+ Defaults to running on `http://127.0.0.1:8000` - use `-p/--port 8002` to run on a different port.
32
+
33
+ Edit multiple commit messages using the web UI. When you apply them the tool will first create a branch to back up the repository prior to making the changes.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install commit-rewriter
39
+ # or
40
+ uv tool install commit-rewriter
41
+ ```
42
+
43
+ ## Screenshot
44
+
45
+ ![Screenshot of the commit-rewriter web interface. A heading reads commit-rewriter above the repository path and current branch and commit hash, with a short description of the tool. A toolbar shows a pending edits count with Discard drafts and Rewrite commit messages buttons, followed by a search box for message, author, or hash and an Edited only checkbox. A left sidebar titled Navigate commits lists recent commit messages with their short hashes. The main panel shows a card for each commit with its hash, author and timestamp, an editable text area containing the commit message, and a View full formatted diff toggle.](https://raw.githubusercontent.com/simonw/commit-rewriter/refs/heads/main/screenshot.webp)
46
+
47
+ ## Contributing
48
+
49
+ To run the tests:
50
+ ```bash
51
+ uv run pytest
52
+ ```
53
+ To re-take the screenshot using [shot-scraper](https://shot-scraper.datasette.io/):
54
+ ```bash
55
+ shot-scraper multi shots.yml
56
+ ```
@@ -0,0 +1,42 @@
1
+ # commit-rewriter
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/commit-rewriter.svg)](https://pypi.org/project/commit-rewriter/)
4
+ [![Changelog](https://img.shields.io/github/v/release/simonw/commit-rewriter?include_prereleases&label=changelog)](https://github.com/simonw/commit-rewriter/releases)
5
+ [![Tests](https://github.com/simonw/commit-rewriter/actions/workflows/test.yml/badge.svg)](https://github.com/simonw/commit-rewriter/actions/workflows/test.yml)
6
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/commit-rewriter/blob/master/LICENSE)
7
+
8
+ Local web app for editing local commit messages to a Git repository.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ uvx commit-rewriter /path/to/repository
14
+ ```
15
+ Or omit the path if the repository is your current working directory.
16
+
17
+ Defaults to running on `http://127.0.0.1:8000` - use `-p/--port 8002` to run on a different port.
18
+
19
+ Edit multiple commit messages using the web UI. When you apply them the tool will first create a branch to back up the repository prior to making the changes.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install commit-rewriter
25
+ # or
26
+ uv tool install commit-rewriter
27
+ ```
28
+
29
+ ## Screenshot
30
+
31
+ ![Screenshot of the commit-rewriter web interface. A heading reads commit-rewriter above the repository path and current branch and commit hash, with a short description of the tool. A toolbar shows a pending edits count with Discard drafts and Rewrite commit messages buttons, followed by a search box for message, author, or hash and an Edited only checkbox. A left sidebar titled Navigate commits lists recent commit messages with their short hashes. The main panel shows a card for each commit with its hash, author and timestamp, an editable text area containing the commit message, and a View full formatted diff toggle.](https://raw.githubusercontent.com/simonw/commit-rewriter/refs/heads/main/screenshot.webp)
32
+
33
+ ## Contributing
34
+
35
+ To run the tests:
36
+ ```bash
37
+ uv run pytest
38
+ ```
39
+ To re-take the screenshot using [shot-scraper](https://shot-scraper.datasette.io/):
40
+ ```bash
41
+ shot-scraper multi shots.yml
42
+ ```
@@ -0,0 +1,361 @@
1
+ """Edit Git commit messages locally. Usage: commit-rewriter [repository-folder]."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import hashlib
6
+ import secrets
7
+ import subprocess
8
+ import threading
9
+ from datetime import datetime, timezone
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+
13
+ from starlette.applications import Starlette
14
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
15
+ from starlette.requests import Request
16
+ from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse
17
+ from starlette.routing import Route
18
+
19
+
20
+ class GitError(Exception):
21
+ pass
22
+
23
+
24
+ class Repository:
25
+ ref = "refs/heads/main"
26
+
27
+ def __init__(self, path):
28
+ self.path = Path(path).resolve()
29
+ self.git("rev-parse", "--git-dir")
30
+ self.tip()
31
+ self.identity = hashlib.sha256(
32
+ self.git("rev-parse", "--path-format=absolute", "--git-common-dir")
33
+ ).hexdigest()
34
+
35
+ def git(self, *args, data=None):
36
+ result = subprocess.run(
37
+ ["git", "--no-replace-objects", "-C", str(self.path), *args],
38
+ input=data,
39
+ stdout=subprocess.PIPE,
40
+ stderr=subprocess.PIPE,
41
+ )
42
+ if result.returncode:
43
+ raise GitError(
44
+ result.stderr.decode(errors="replace").strip() or "Git command failed"
45
+ )
46
+ return result.stdout
47
+
48
+ def tip(self):
49
+ return self.git("rev-parse", "--verify", self.ref).decode().strip()
50
+
51
+ def raw(self, oid):
52
+ return self.git("cat-file", "commit", oid)
53
+
54
+ def diff(self, oid):
55
+ """Return Git's complete, colorless patch presentation for a commit."""
56
+ return self.git(
57
+ "show",
58
+ "--format=fuller",
59
+ "--patch",
60
+ "--binary",
61
+ "--no-ext-diff",
62
+ "--no-color",
63
+ oid,
64
+ ).decode(errors="replace")
65
+
66
+ def commits(self, include_diffs=True):
67
+ commits = []
68
+ for oid in (
69
+ self.git("log", "-100", "--format=%H", self.ref).decode().splitlines()
70
+ ):
71
+ header, message = self.raw(oid).split(b"\n\n", 1)
72
+ fields = header.splitlines()
73
+ author = next(line[7:] for line in fields if line.startswith(b"author "))
74
+ committer = next(
75
+ line[10:] for line in fields if line.startswith(b"committer ")
76
+ )
77
+ encoding = next(
78
+ (
79
+ line[9:].decode("ascii")
80
+ for line in fields
81
+ if line.startswith(b"encoding ")
82
+ ),
83
+ "utf-8",
84
+ )
85
+ try:
86
+ decoded = message.decode(encoding)
87
+ except (LookupError, UnicodeError) as exc:
88
+ raise GitError(
89
+ f"Cannot decode message for {oid[:12]} using {encoding}"
90
+ ) from exc
91
+ commit = {
92
+ "oid": oid,
93
+ "message": decoded,
94
+ "author": author.decode(errors="replace"),
95
+ "committer": committer.decode(errors="replace"),
96
+ }
97
+ if include_diffs:
98
+ commit["diff"] = self.diff(oid)
99
+ commits.append(commit)
100
+ return commits
101
+
102
+ def check(self, expected):
103
+ if self.tip() != expected:
104
+ raise GitError(
105
+ "main changed. Reload and review your drafts before rewriting."
106
+ )
107
+ if self.git("rev-parse", "--is-shallow-repository").strip() == b"true":
108
+ raise GitError("A complete clone is required; this repository is shallow.")
109
+ # Rewrites preserve the trees and leave pending index/worktree changes alone.
110
+ if self.git("ls-files", "--unmerged"):
111
+ raise GitError("Resolve index conflicts before rewriting.")
112
+ for marker in (
113
+ "MERGE_HEAD",
114
+ "CHERRY_PICK_HEAD",
115
+ "REVERT_HEAD",
116
+ "rebase-merge",
117
+ "rebase-apply",
118
+ "BISECT_LOG",
119
+ "sequencer",
120
+ ):
121
+ path = self.git("rev-parse", "--git-path", marker).decode().strip()
122
+ if (self.path / path).exists():
123
+ raise GitError("Finish the active Git operation before rewriting.")
124
+ # Other worktrees could have their own in-progress Git operations.
125
+ records = self.git("worktree", "list", "--porcelain").decode().split("\n\n")
126
+ current = self.git("rev-parse", "--show-toplevel").decode().strip()
127
+ for record in records:
128
+ lines = record.splitlines()
129
+ if (
130
+ f"branch {self.ref}" in lines
131
+ and lines
132
+ and Path(lines[0][9:]).resolve() != Path(current).resolve()
133
+ ):
134
+ raise GitError(
135
+ "main is checked out in another worktree. Run this server there."
136
+ )
137
+
138
+ def prepare(self, expected, edits):
139
+ self.check(expected)
140
+ if not isinstance(edits, dict) or not edits:
141
+ raise GitError("Provide at least one message edit.")
142
+ allowed = {c["oid"]: c for c in self.commits(include_diffs=False)}
143
+ result = {}
144
+ for oid, message in edits.items():
145
+ if oid not in allowed or not isinstance(message, str):
146
+ raise GitError("Edits must refer to the latest 100 commits on main.")
147
+ if not message.strip() or "\x00" in message:
148
+ raise GitError(
149
+ "Commit messages cannot be empty or contain NUL characters."
150
+ )
151
+ if message != allowed[oid]["message"]:
152
+ header = self.raw(oid).split(b"\n\n", 1)[0]
153
+ encoding = next(
154
+ (
155
+ line[9:].decode("ascii")
156
+ for line in header.splitlines()
157
+ if line.startswith(b"encoding ")
158
+ ),
159
+ "utf-8",
160
+ )
161
+ try:
162
+ result[oid] = message.encode(encoding)
163
+ except (LookupError, UnicodeError) as exc:
164
+ raise GitError(f"Message cannot be encoded as {encoding}.") from exc
165
+ if not result:
166
+ raise GitError("No changed messages to rewrite.")
167
+ return result
168
+
169
+ def rewrite(self, expected, edits, report):
170
+ self.check(expected)
171
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H%M%S-%f")
172
+ backup = f"commit-message-backup/{stamp}-{secrets.token_hex(3)}"
173
+ self.git("update-ref", f"refs/heads/{backup}", expected, "0" * len(expected))
174
+ report(backup=backup)
175
+ mapping = {}
176
+ rows = (
177
+ self.git("rev-list", "--topo-order", "--reverse", "--parents", expected)
178
+ .decode()
179
+ .splitlines()
180
+ )
181
+ affected = set()
182
+ work = []
183
+ for row in rows:
184
+ oid, *parents = row.split()
185
+ if oid in edits or any(parent in affected for parent in parents):
186
+ affected.add(oid)
187
+ work.append(oid)
188
+ report(total=len(work))
189
+ signatures_removed = 0
190
+ for index, oid in enumerate(work):
191
+ header, message = self.raw(oid).split(b"\n\n", 1)
192
+ output = []
193
+ skip = False
194
+ for line in header.split(b"\n"):
195
+ if line.startswith(b" "):
196
+ if not skip:
197
+ output.append(line)
198
+ continue
199
+ # Signatures authenticate the old object/parents and cannot be retained.
200
+ skip = line.startswith((b"gpgsig ", b"gpgsig-sha256 ", b"mergetag "))
201
+ if skip:
202
+ signatures_removed += 1
203
+ elif line.startswith(b"parent "):
204
+ parent = line[7:].decode()
205
+ output.append(b"parent " + mapping.get(parent, parent).encode())
206
+ else:
207
+ output.append(line)
208
+ raw = b"\n".join(output) + b"\n\n" + edits.get(oid, message)
209
+ mapping[oid] = (
210
+ self.git("hash-object", "-t", "commit", "-w", "--stdin", data=raw)
211
+ .decode()
212
+ .strip()
213
+ )
214
+ report(done=index + 1)
215
+ # Compare-and-swap protects against concurrent branch updates; the tree is unchanged.
216
+ self.check(expected)
217
+ new_tip = mapping[expected]
218
+ self.git(
219
+ "update-ref",
220
+ "-m",
221
+ f"commit-rewriter: backup {backup}",
222
+ self.ref,
223
+ new_tip,
224
+ expected,
225
+ )
226
+ report(
227
+ status="complete",
228
+ tip=new_tip,
229
+ mapping=mapping,
230
+ signatures_removed=signatures_removed,
231
+ )
232
+
233
+
234
+ def create_app(path):
235
+ repo = Repository(path)
236
+ assets = files(__package__)
237
+ template = assets.joinpath("index.html").read_text(encoding="utf-8")
238
+ script = assets.joinpath("app.js").read_text(encoding="utf-8")
239
+ token = secrets.token_urlsafe(32)
240
+ lock = threading.Lock()
241
+ job = {"status": "idle", "done": 0, "total": 0}
242
+ tasks = set()
243
+
244
+ def report(**fields):
245
+ with lock:
246
+ job.update(fields)
247
+
248
+ async def home(request):
249
+ return HTMLResponse(
250
+ template.replace("__TOKEN__", token),
251
+ headers={
252
+ "Cache-Control": "no-store",
253
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
254
+ },
255
+ )
256
+
257
+ async def javascript(request):
258
+ return PlainTextResponse(
259
+ script.replace("__TOKEN__", token),
260
+ media_type="application/javascript",
261
+ headers={"Cache-Control": "no-store"},
262
+ )
263
+
264
+ async def state(request):
265
+ try:
266
+ tip = await asyncio.to_thread(repo.tip)
267
+ commits = await asyncio.to_thread(repo.commits)
268
+ if await asyncio.to_thread(repo.tip) != tip:
269
+ raise GitError("main changed during loading. Reload the page.")
270
+ return JSONResponse(
271
+ {
272
+ "repository": str(repo.path),
273
+ "identity": repo.identity,
274
+ "tip": tip,
275
+ "commits": commits,
276
+ },
277
+ headers={"Cache-Control": "no-store"},
278
+ )
279
+ except GitError as exc:
280
+ return JSONResponse({"error": str(exc)}, status_code=409)
281
+
282
+ async def progress(request):
283
+ with lock:
284
+ snapshot = dict(job)
285
+ return JSONResponse(snapshot, headers={"Cache-Control": "no-store"})
286
+
287
+ async def execute(expected, edits):
288
+ try:
289
+ prepared = await asyncio.to_thread(repo.prepare, expected, edits)
290
+ await asyncio.to_thread(repo.rewrite, expected, prepared, report)
291
+ except Exception as exc:
292
+ report(status="failed", error=str(exc))
293
+
294
+ async def rewrite(request: Request):
295
+ if request.headers.get("x-csrf-token") != token:
296
+ return JSONResponse(
297
+ {"error": "Invalid request token. Reload this page."}, status_code=403
298
+ )
299
+ try:
300
+ body = await request.json()
301
+ if (
302
+ not isinstance(body, dict)
303
+ or not isinstance(body.get("tip"), str)
304
+ or not isinstance(body.get("edits"), dict)
305
+ ):
306
+ raise ValueError()
307
+ except (ValueError, TypeError):
308
+ return JSONResponse(
309
+ {"error": "Expected a tip and an edits object."}, status_code=400
310
+ )
311
+ with lock:
312
+ if job["status"] == "running":
313
+ return JSONResponse(
314
+ {"error": "A rewrite is already running."}, status_code=409
315
+ )
316
+ job.clear()
317
+ job.update(
318
+ status="running",
319
+ done=0,
320
+ total=0,
321
+ original_tip=body["tip"],
322
+ edits=body["edits"],
323
+ )
324
+ task = asyncio.create_task(execute(body["tip"], body["edits"]))
325
+ tasks.add(task)
326
+ task.add_done_callback(tasks.discard)
327
+ return JSONResponse({"status": "running"}, status_code=202)
328
+
329
+ app = Starlette(
330
+ routes=[
331
+ Route("/", home),
332
+ Route("/app.js", javascript),
333
+ Route("/api/state", state),
334
+ Route("/api/progress", progress),
335
+ Route("/api/rewrite", rewrite, methods=["POST"]),
336
+ ]
337
+ )
338
+ app.add_middleware(
339
+ TrustedHostMiddleware,
340
+ allowed_hosts=["localhost", "127.0.0.1", "[::1]", "testserver"],
341
+ )
342
+ return app
343
+
344
+
345
+ def main():
346
+ parser = argparse.ArgumentParser(description=__doc__)
347
+ parser.add_argument(
348
+ "path",
349
+ nargs="?",
350
+ default=".",
351
+ help="Repository directory (default: current directory)",
352
+ )
353
+ parser.add_argument("-p", "--port", type=int, default=8000)
354
+ args = parser.parse_args()
355
+ try:
356
+ app = create_app(args.path)
357
+ except GitError as exc:
358
+ parser.error(str(exc))
359
+ import uvicorn
360
+
361
+ uvicorn.run(app, host="127.0.0.1", port=args.port)
@@ -0,0 +1,4 @@
1
+ from . import main
2
+
3
+ if __name__ == "__main__":
4
+ main()