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.
- commit_rewriter/__init__.py +361 -0
- commit_rewriter/__main__.py +4 -0
- commit_rewriter/app.js +324 -0
- commit_rewriter/index.html +322 -0
- commit_rewriter-0.1.dist-info/METADATA +56 -0
- commit_rewriter-0.1.dist-info/RECORD +9 -0
- commit_rewriter-0.1.dist-info/WHEEL +5 -0
- commit_rewriter-0.1.dist-info/entry_points.txt +2 -0
- commit_rewriter-0.1.dist-info/top_level.txt +1 -0
|
@@ -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)
|
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
|
+
[](https://pypi.org/project/commit-rewriter/)
|
|
18
|
+
[](https://github.com/simonw/commit-rewriter/releases)
|
|
19
|
+
[](https://github.com/simonw/commit-rewriter/actions/workflows/test.yml)
|
|
20
|
+
[](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
|
+

|
|
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 @@
|
|
|
1
|
+
commit_rewriter
|