commit-rewriter 0.1__py3-none-any.whl

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,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()
commit_rewriter/app.js ADDED
@@ -0,0 +1,324 @@
1
+ new ResizeObserver((entries) =>
2
+ document.documentElement.style.setProperty(
3
+ "--toolbar-height",
4
+ entries[0].target.offsetHeight + "px",
5
+ ),
6
+ ).observe(document.querySelector(".toolbar"));
7
+ const $ = (id) => document.getElementById(id);
8
+ let state,
9
+ drafts = {},
10
+ key,
11
+ busy = false,
12
+ stale = false,
13
+ polling = false;
14
+ const el = (tag, text, cls) => {
15
+ const n = document.createElement(tag);
16
+ if (text !== undefined) n.textContent = text;
17
+ if (cls) n.className = cls;
18
+ return n;
19
+ };
20
+ function status(text, error = false) {
21
+ $("status").textContent = text;
22
+ $("status").className = error ? "error" : "";
23
+ }
24
+ async function api(url, options) {
25
+ const r = await fetch(url, options);
26
+ const body = await r.json();
27
+ if (!r.ok) throw Error(body.error || "Request failed");
28
+ return body;
29
+ }
30
+ function save() {
31
+ try {
32
+ localStorage.setItem(
33
+ key,
34
+ JSON.stringify({ tip: state.tip, edits: drafts }),
35
+ );
36
+ } catch (e) {
37
+ status(
38
+ "Draft storage is unavailable. Keep this page open to retain edits.",
39
+ true,
40
+ );
41
+ }
42
+ }
43
+ function counts() {
44
+ const n = Object.keys(drafts).length;
45
+ const invalid = Object.values(drafts).some(
46
+ (m) => !m.trim() || m.includes("\0"),
47
+ );
48
+ $("count").textContent = `${n} pending edit${n === 1 ? "" : "s"}`;
49
+ $("review").textContent = `Rewrite ${n} commit message${n === 1 ? "" : "s"}`;
50
+ $("review").disabled = !n || invalid || busy || stale;
51
+ $("discard").disabled = busy;
52
+ }
53
+ function validation(area, note) {
54
+ if (!area.value.trim() || area.value.includes("\0")) {
55
+ note.textContent = "Message cannot be empty or contain NUL characters.";
56
+ note.className = "validation error";
57
+ area.setAttribute("aria-invalid", "true");
58
+ } else {
59
+ const length = Array.from(area.value.split("\n")[0]).length;
60
+ note.textContent =
61
+ length > 72
62
+ ? `Long subject: ${length} characters (recommended maximum: 72). You can still apply this edit.`
63
+ : "";
64
+ note.className = "validation warning";
65
+ area.removeAttribute("aria-invalid");
66
+ }
67
+ }
68
+ function navigation() {
69
+ const nav = $("navigation");
70
+ nav.replaceChildren();
71
+ let visible = 0;
72
+ for (const c of state.commits) {
73
+ const card = document.getElementById("commit-" + c.oid);
74
+ if (!card || card.hidden) continue;
75
+ visible++;
76
+ const link = el(
77
+ "a",
78
+ (drafts[c.oid] ?? c.message).split("\n")[0] || "(empty subject)",
79
+ "nav-link",
80
+ );
81
+ link.href = "#commit-" + c.oid;
82
+ link.classList.toggle("pending", c.oid in drafts);
83
+ link.append(
84
+ el("small", c.oid.slice(0, 12) + (c.oid in drafts ? " · edited" : "")),
85
+ );
86
+ nav.append(link);
87
+ }
88
+ $("noMatches").classList.toggle("hidden", visible > 0);
89
+ }
90
+ function filter() {
91
+ const q = $("search").value.toLowerCase();
92
+ for (const card of $("commits").children) {
93
+ if (!card.dataset.oid) continue;
94
+ const c = state.commits.find((c) => c.oid === card.dataset.oid);
95
+ card.hidden =
96
+ ($("only").checked && !(c.oid in drafts)) ||
97
+ !`${c.oid} ${c.author} ${drafts[c.oid] ?? c.message}`
98
+ .toLowerCase()
99
+ .includes(q);
100
+ }
101
+ navigation();
102
+ }
103
+ function resizeTextarea(area) {
104
+ area.style.height = "auto";
105
+ area.style.height = area.scrollHeight + "px";
106
+ }
107
+ function render() {
108
+ $("commits").replaceChildren();
109
+ for (const c of state.commits) {
110
+ const card = el("article");
111
+ card.dataset.oid = c.oid;
112
+ card.id = "commit-" + c.oid;
113
+ card.classList.toggle("edited", c.oid in drafts);
114
+ card.append(el("div", `${c.oid.slice(0, 12)} · ${c.author}`, "meta"));
115
+ const area = el("textarea");
116
+ area.value = drafts[c.oid] ?? c.message;
117
+ area.disabled = busy || stale;
118
+ area.setAttribute("aria-label", `Message for commit ${c.oid.slice(0, 12)}`);
119
+ area.spellcheck = false;
120
+ const note = el("div", undefined, "validation");
121
+ note.id = `v-${c.oid}`;
122
+ area.setAttribute("aria-describedby", note.id);
123
+ validation(area, note);
124
+ resizeTextarea(area);
125
+ area.oninput = () => {
126
+ resizeTextarea(area);
127
+ if (area.value === c.message) delete drafts[c.oid];
128
+ else drafts[c.oid] = area.value;
129
+ card.classList.toggle("edited", c.oid in drafts);
130
+ validation(area, note);
131
+ save();
132
+ counts();
133
+ filter();
134
+ };
135
+ const diff = el("details");
136
+ diff.append(el("summary", "View full formatted diff"), el("pre", c.diff));
137
+ card.append(area, note, diff);
138
+ $("commits").append(card);
139
+ }
140
+ counts();
141
+ filter();
142
+ }
143
+ async function load() {
144
+ try {
145
+ state = await api("/api/state");
146
+ key = "commit-rewriter:" + state.identity;
147
+ drafts = {};
148
+ stale = false;
149
+ $("repo").textContent =
150
+ state.repository + " · main @ " + state.tip.slice(0, 12);
151
+ try {
152
+ const saved = JSON.parse(localStorage.getItem(key) || "null");
153
+ const completed = await api("/api/progress");
154
+ if (
155
+ saved &&
156
+ completed.status === "complete" &&
157
+ saved.tip === completed.original_tip &&
158
+ state.tip === completed.tip
159
+ ) {
160
+ saved.edits = Object.fromEntries(
161
+ Object.entries(saved.edits || {})
162
+ .filter(([oid, m]) => completed.edits[oid] !== m)
163
+ .map(([oid, m]) => [completed.mapping[oid] || oid, m]),
164
+ );
165
+ saved.tip = state.tip;
166
+ localStorage.setItem(key, JSON.stringify(saved));
167
+ }
168
+ if (saved && saved.edits && typeof saved.edits === "object") {
169
+ drafts = Object.fromEntries(
170
+ Object.entries(saved.edits).filter(
171
+ ([oid, m]) => typeof m === "string",
172
+ ),
173
+ );
174
+ if (saved.tip !== state.tip && Object.keys(drafts).length) {
175
+ stale = true;
176
+ status(
177
+ "main has changed since these drafts were saved. Copy any draft text you need, then discard the stale drafts to continue.",
178
+ true,
179
+ );
180
+ } else {
181
+ const ids = new Set(state.commits.map((c) => c.oid));
182
+ drafts = Object.fromEntries(
183
+ Object.entries(drafts).filter(([oid]) => ids.has(oid)),
184
+ );
185
+ }
186
+ }
187
+ } catch (e) {
188
+ status(
189
+ "Saved drafts could not be loaded. Browser storage may be unavailable.",
190
+ true,
191
+ );
192
+ }
193
+ render();
194
+ if (stale) {
195
+ const details = el("details");
196
+ details.open = true;
197
+ details.append(el("summary", "Saved drafts from previous history"));
198
+ for (const [oid, m] of Object.entries(drafts))
199
+ details.append(el("pre", oid + "\n" + m));
200
+ $("commits").prepend(details);
201
+ }
202
+ } catch (e) {
203
+ status(e.message, true);
204
+ }
205
+ }
206
+ $("search").oninput = filter;
207
+ $("only").onchange = filter;
208
+ $("discard").onclick = () => {
209
+ if (confirm("Discard all saved message drafts?")) {
210
+ drafts = {};
211
+ stale = false;
212
+ save();
213
+ status("Drafts discarded.");
214
+ render();
215
+ }
216
+ };
217
+ $("review").onclick = () => {
218
+ $("changes").replaceChildren();
219
+ for (const c of state.commits.filter((c) => c.oid in drafts)) {
220
+ const section = el("section");
221
+ section.append(el("h3", c.oid.slice(0, 12)));
222
+ const grid = el("div", undefined, "comparison");
223
+ for (const [label, message] of [
224
+ ["Original", c.message],
225
+ ["Proposed", drafts[c.oid]],
226
+ ]) {
227
+ const col = el("div");
228
+ col.append(el("strong", label), el("pre", message));
229
+ grid.append(col);
230
+ }
231
+ section.append(grid);
232
+ $("changes").append(section);
233
+ }
234
+ $("dialog").showModal();
235
+ };
236
+ $("cancel").onclick = () => $("dialog").close();
237
+ $("reload").onclick = () => location.reload();
238
+ async function poll() {
239
+ if (polling) return;
240
+ polling = true;
241
+ try {
242
+ while (true) {
243
+ const job = await api("/api/progress");
244
+ if (job.status === "idle") break;
245
+ busy = job.status === "running";
246
+ $("progressBox").classList.remove("hidden");
247
+ $("bar").max = job.total || 1;
248
+ $("bar").value = job.done;
249
+ $("progressLabel").textContent = job.total
250
+ ? `${job.done} / ${job.total} affected commits rebuilt`
251
+ : "Validating edits and preparing backup…";
252
+ counts();
253
+ if (job.status === "complete") {
254
+ drafts = {};
255
+ await load();
256
+ status(
257
+ `Rewrite complete. Backup branch: ${job.backup}\n${job.signatures_removed} signature headers removed.`,
258
+ );
259
+ const details = el("details");
260
+ details.append(
261
+ el("summary", "Old → new commit hashes"),
262
+ el(
263
+ "pre",
264
+ Object.entries(job.mapping)
265
+ .map(([a, b]) => `${a} → ${b}`)
266
+ .join("\n"),
267
+ ),
268
+ );
269
+ $("status").append(details);
270
+ break;
271
+ }
272
+ if (job.status === "failed") {
273
+ status(
274
+ `Rewrite failed: ${job.error}` +
275
+ (job.backup ? `\nBackup branch: ${job.backup}` : ""),
276
+ true,
277
+ );
278
+ $("reload").classList.remove("hidden");
279
+ render();
280
+ break;
281
+ }
282
+ await new Promise((r) => setTimeout(r, 250));
283
+ }
284
+ } catch (e) {
285
+ status(
286
+ "Connection lost. Reload to check the rewrite status. " + e.message,
287
+ true,
288
+ );
289
+ $("reload").classList.remove("hidden");
290
+ } finally {
291
+ polling = false;
292
+ }
293
+ }
294
+ $("apply").onclick = async () => {
295
+ $("dialog").close();
296
+ busy = true;
297
+ render();
298
+ status("");
299
+ try {
300
+ await api("/api/rewrite", {
301
+ method: "POST",
302
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": TOKEN },
303
+ body: JSON.stringify({ tip: state.tip, edits: drafts }),
304
+ });
305
+ await poll();
306
+ } catch (e) {
307
+ busy = false;
308
+ render();
309
+ status(e.message, true);
310
+ }
311
+ };
312
+ (async () => {
313
+ await load();
314
+ if (state) {
315
+ const job = await api("/api/progress");
316
+ if (job.status === "running") {
317
+ busy = true;
318
+ render();
319
+ await poll();
320
+ } else if (job.status === "complete" && stale) {
321
+ await poll();
322
+ }
323
+ }
324
+ })().catch((e) => status(e.message, true));
@@ -0,0 +1,322 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>commit-rewriter</title>
7
+ <style>
8
+ :root {
9
+ font-family: system-ui, sans-serif;
10
+ color: #202b3b;
11
+ background: #f5f7fa;
12
+ color-scheme: light;
13
+ }
14
+ * {
15
+ box-sizing: border-box;
16
+ }
17
+ body {
18
+ margin: 0;
19
+ }
20
+ main {
21
+ max-width: 1360px;
22
+ margin: auto;
23
+ padding: 24px 24px 40px;
24
+ }
25
+ h1 {
26
+ font-size: 36px;
27
+ letter-spacing: -1.5px;
28
+ margin: 0 0 8px;
29
+ }
30
+ p {
31
+ color: #5d6a7e;
32
+ line-height: 1.6;
33
+ }
34
+ .toolbar {
35
+ position: sticky;
36
+ top: 0;
37
+ background: #f5f7fa;
38
+ padding: 18px 0;
39
+ z-index: 2;
40
+ border-bottom: 1px solid #d8dfe8;
41
+ }
42
+ .row {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 14px;
46
+ flex-wrap: wrap;
47
+ }
48
+ button,
49
+ input,
50
+ textarea {
51
+ font: inherit;
52
+ border-radius: 7px;
53
+ border: 1px solid #c1cbd8;
54
+ padding: 11px;
55
+ background: #ffffff;
56
+ color: inherit;
57
+ }
58
+ button {
59
+ cursor: pointer;
60
+ }
61
+ button.primary {
62
+ background: #16734e;
63
+ color: #ffffff;
64
+ font-weight: 750;
65
+ border: 0;
66
+ padding: 15px 24px;
67
+ }
68
+ button:disabled {
69
+ opacity: 0.45;
70
+ cursor: not-allowed;
71
+ }
72
+ input[type="search"] {
73
+ flex: 1;
74
+ min-width: 180px;
75
+ }
76
+ label {
77
+ font-size: 14px;
78
+ }
79
+ .count {
80
+ font-weight: 700;
81
+ margin-right: auto;
82
+ }
83
+ article {
84
+ background: #ffffff;
85
+ border: 1px solid #d8dfe8;
86
+ border-radius: 10px;
87
+ padding: 20px;
88
+ margin: 18px 0;
89
+ }
90
+ article.edited {
91
+ border-color: #16734e;
92
+ }
93
+ .meta {
94
+ font:
95
+ 12px ui-monospace,
96
+ monospace;
97
+ color: #5d6a7e;
98
+ margin-bottom: 12px;
99
+ overflow-wrap: anywhere;
100
+ }
101
+ textarea {
102
+ width: 100%;
103
+ resize: vertical;
104
+ min-height: 105px;
105
+ line-height: 1.5;
106
+ background: #ffffff;
107
+ }
108
+ .warning {
109
+ color: #8b5700;
110
+ }
111
+ .error {
112
+ color: #b42318;
113
+ }
114
+ .validation {
115
+ min-height: 23px;
116
+ font-size: 13px;
117
+ margin-top: 7px;
118
+ }
119
+ .hidden {
120
+ display: none !important;
121
+ }
122
+ progress {
123
+ width: 100%;
124
+ height: 18px;
125
+ accent-color: #16734e;
126
+ }
127
+ #status {
128
+ white-space: pre-wrap;
129
+ overflow-wrap: anywhere;
130
+ }
131
+ dialog {
132
+ max-width: 900px;
133
+ width: 95%;
134
+ max-height: 85vh;
135
+ background: #ffffff;
136
+ border: 1px solid #c1cbd8;
137
+ border-radius: 12px;
138
+ color: inherit;
139
+ }
140
+ dialog::backdrop {
141
+ background: #000a;
142
+ }
143
+ .comparison {
144
+ display: grid;
145
+ grid-template-columns: 1fr 1fr;
146
+ gap: 12px;
147
+ }
148
+ pre {
149
+ white-space: pre-wrap;
150
+ overflow-wrap: anywhere;
151
+ background: #eef2f6;
152
+ padding: 12px;
153
+ font-size: 13px;
154
+ }
155
+ details {
156
+ margin: 16px 0;
157
+ }
158
+ footer {
159
+ font-size: 12px;
160
+ color: #637086;
161
+ margin-top: 30px;
162
+ }
163
+ @media (max-width: 600px) {
164
+ .comparison {
165
+ grid-template-columns: 1fr;
166
+ }
167
+ main {
168
+ padding: 24px 14px;
169
+ }
170
+ h1 {
171
+ font-size: 30px;
172
+ }
173
+ }
174
+ .layout {
175
+ display: grid;
176
+ grid-template-columns: 260px minmax(0, 1fr);
177
+ gap: 24px;
178
+ align-items: start;
179
+ }
180
+ .sidebar {
181
+ position: sticky;
182
+ top: calc(var(--toolbar-height, 140px) + 12px);
183
+ border: 1px solid #d8dfe8;
184
+ border-radius: 10px;
185
+ background: white;
186
+ margin-top: 18px;
187
+ padding: 14px;
188
+ }
189
+ .sidebar summary {
190
+ font-weight: 700;
191
+ cursor: pointer;
192
+ }
193
+ .sidebar nav {
194
+ max-height: calc(100vh - var(--toolbar-height, 140px) - 110px);
195
+ overflow: auto;
196
+ margin-top: 12px;
197
+ }
198
+ .nav-link {
199
+ display: block;
200
+ padding: 10px 8px;
201
+ border-radius: 6px;
202
+ color: #334155;
203
+ text-decoration: none;
204
+ font-size: 13px;
205
+ border-left: 3px solid transparent;
206
+ line-height: 1.4;
207
+ }
208
+ .nav-link:hover,
209
+ .nav-link:focus {
210
+ background: #eef5f1;
211
+ }
212
+ .nav-link.pending {
213
+ border-left-color: #16734e;
214
+ }
215
+ .nav-link small {
216
+ display: block;
217
+ color: #64748b;
218
+ font:
219
+ 11px ui-monospace,
220
+ monospace;
221
+ margin-top: 4px;
222
+ }
223
+ article {
224
+ scroll-margin-top: calc(var(--toolbar-height, 140px) + 18px);
225
+ }
226
+ article:target {
227
+ outline: 2px solid #16734e;
228
+ outline-offset: 3px;
229
+ }
230
+ .empty {
231
+ color: #64748b;
232
+ font-size: 13px;
233
+ }
234
+ @media (max-width: 760px) {
235
+ .layout {
236
+ grid-template-columns: 1fr;
237
+ gap: 0;
238
+ }
239
+ .sidebar {
240
+ position: static;
241
+ }
242
+ .sidebar nav {
243
+ max-height: 180px;
244
+ }
245
+ article {
246
+ scroll-margin-top: calc(var(--toolbar-height, 200px) + 18px);
247
+ }
248
+ }
249
+ </style>
250
+ </head>
251
+ <body>
252
+ <main>
253
+ <h1>commit-rewriter</h1>
254
+ <p id="repo">Loading repository…</p>
255
+ <p>
256
+ Edit the latest 100 commits on <strong>main</strong>. Author and
257
+ committer identities and dates stay intact. A timestamped backup branch
258
+ is created before every rewrite.
259
+ </p>
260
+ <div class="toolbar">
261
+ <div class="row">
262
+ <span class="count" id="count" aria-live="polite"
263
+ >0 pending edits</span
264
+ ><button id="discard">Discard drafts</button
265
+ ><button class="primary" id="review" disabled>
266
+ Rewrite 0 commit messages
267
+ </button>
268
+ </div>
269
+ <div class="row" style="margin-top: 14px">
270
+ <input
271
+ id="search"
272
+ type="search"
273
+ aria-label="Search commits"
274
+ placeholder="Search message, author, or hash"
275
+ /><label><input id="only" type="checkbox" /> Edited only</label>
276
+ </div>
277
+ <div id="progressBox" class="hidden">
278
+ <p id="progressLabel" aria-live="polite"></p>
279
+ <progress
280
+ id="bar"
281
+ max="1"
282
+ value="0"
283
+ aria-label="Rewrite progress"
284
+ ></progress>
285
+ </div>
286
+ </div>
287
+ <p id="status" role="status"></p>
288
+ <button id="reload" class="hidden">Reload current history</button>
289
+ <div class="layout">
290
+ <aside class="sidebar">
291
+ <details open style="margin: 0">
292
+ <summary>Navigate commits</summary>
293
+ <nav id="navigation" aria-label="Commit navigation"></nav>
294
+ <p id="noMatches" class="empty hidden">No matching commits</p>
295
+ </details>
296
+ </aside>
297
+ <section id="commits" aria-label="Commit editors"></section>
298
+ </div>
299
+ <footer>
300
+ Drafts are stored in this browser’s localStorage. Rewrites change
301
+ descendant hashes and remove invalidated commit signatures. No changes
302
+ are pushed.
303
+ </footer>
304
+ <dialog id="dialog">
305
+ <h2>Review message changes</h2>
306
+ <p>
307
+ main will be updated after a backup branch is created. File contents,
308
+ author information, and both dates are preserved.
309
+ </p>
310
+ <div id="changes"></div>
311
+ <div class="row">
312
+ <button id="cancel">Keep editing</button
313
+ ><button id="apply" class="primary">Back up & rewrite</button>
314
+ </div>
315
+ </dialog>
316
+ <script>
317
+ const TOKEN = "__TOKEN__";
318
+ </script>
319
+ <script src="/app.js"></script>
320
+ </main>
321
+ </body>
322
+ </html>
@@ -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,9 @@
1
+ commit_rewriter/__init__.py,sha256=f1aycQs_3axcIcswHYg88QaRlWAcd8EQu5t6ZzyEngY,13130
2
+ commit_rewriter/__main__.py,sha256=5BjNuyet8AY-POwoF5rGt722rHQ7tJ0Vf0UFUfzzi-I,58
3
+ commit_rewriter/app.js,sha256=M9Spzyjch03Cwpfll2iwFRkCTi2yzuHcZ__OzK0rrSo,9824
4
+ commit_rewriter/index.html,sha256=4UJMqDL-JCs0sQKy0r0XNcRzUyxjQEf6V55SN_JxVTk,7993
5
+ commit_rewriter-0.1.dist-info/METADATA,sha256=Dx09GiUtu8sLxrFu6MqjKywR5KhcWrweTp1Vbszve3s,2630
6
+ commit_rewriter-0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ commit_rewriter-0.1.dist-info/entry_points.txt,sha256=d6CfDOAjgLhTMfWZmLf-sEILQYz-y_G5rXQPPBrmGJo,57
8
+ commit_rewriter-0.1.dist-info/top_level.txt,sha256=9f8O1kyP1WF4-CmlorzvJ9R0BpljWCVtcjperFJ2Oto,16
9
+ commit_rewriter-0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ commit-rewriter = commit_rewriter:main
@@ -0,0 +1 @@
1
+ commit_rewriter