Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: show automation gallery in public profile

Hugo c93f11b6 f8e3d456

+456 -125
+81
app/components/AutomationGallery/index.tsx
··· 1 + import type { AutomationSearchResult } from "../../../lib/automations/search.ts"; 2 + import { AutomationCard } from "../AutomationCard/index.tsx"; 3 + import { Button } from "../Button/index.tsx"; 4 + import * as s from "../../styles/pages/automations.css.ts"; 5 + 6 + type Props = { 7 + rows: AutomationSearchResult[]; 8 + featured?: AutomationSearchResult[]; 9 + viewerDid?: string; 10 + viewerAuthenticated: boolean; 11 + hasMore: boolean; 12 + loadMoreHref: string; 13 + emptyMessage?: string; 14 + }; 15 + 16 + export function AutomationGallery({ 17 + rows, 18 + featured = [], 19 + viewerDid, 20 + viewerAuthenticated, 21 + hasMore, 22 + loadMoreHref, 23 + emptyMessage = "No automations match your search. Try a broader search.", 24 + }: Props) { 25 + return ( 26 + <div id="automation-results"> 27 + {featured.length > 0 && ( 28 + <section class={s.featuredSection}> 29 + <h2 class={s.featuredTitle}>Featured</h2> 30 + <div class={s.grid}> 31 + {featured.map((a) => ( 32 + <AutomationCard 33 + key={`${a.did}/${a.rkey}`} 34 + handle={a.handle} 35 + did={a.did} 36 + rkey={a.rkey} 37 + name={a.name} 38 + description={a.description} 39 + lexicon={a.lexicon} 40 + actions={a.actions} 41 + viewerAuthenticated={viewerAuthenticated} 42 + isOwner={viewerDid === a.did} 43 + featured 44 + /> 45 + ))} 46 + </div> 47 + </section> 48 + )} 49 + 50 + {rows.length > 0 ? ( 51 + <> 52 + <div class={s.grid}> 53 + {rows.map((a) => ( 54 + <AutomationCard 55 + key={`${a.did}/${a.rkey}`} 56 + handle={a.handle} 57 + did={a.did} 58 + rkey={a.rkey} 59 + name={a.name} 60 + description={a.description} 61 + lexicon={a.lexicon} 62 + actions={a.actions} 63 + viewerAuthenticated={viewerAuthenticated} 64 + isOwner={viewerDid === a.did} 65 + /> 66 + ))} 67 + </div> 68 + {hasMore && ( 69 + <div class={s.loadMoreWrap}> 70 + <Button href={loadMoreHref} variant="secondary" size="sm"> 71 + Load more 72 + </Button> 73 + </div> 74 + )} 75 + </> 76 + ) : featured.length === 0 ? ( 77 + <p class={s.empty}>{emptyMessage}</p> 78 + ) : null} 79 + </div> 80 + ); 81 + }
+7 -5
app/islands/AutomationFilters.tsx
··· 4 4 5 5 type Props = { 6 6 q: string; 7 + basePath?: string; 7 8 }; 8 9 9 10 const DEBOUNCE_MS = 300; 10 11 const BUSY_DELAY_MS = 120; 11 12 const MIN_Q_LENGTH = 2; 12 13 const RESULTS_ID = "automation-results"; 14 + const DEFAULT_BASE_PATH = "/automations"; 13 15 14 - const buildDisplayUrl = (params: URLSearchParams) => { 16 + const buildDisplayUrl = (basePath: string, params: URLSearchParams) => { 15 17 const qs = params.toString(); 16 - return qs ? `/automations?${qs}` : "/automations"; 18 + return qs ? `${basePath}?${qs}` : basePath; 17 19 }; 18 20 19 21 const paramsFromFilters = (q: string) => { ··· 42 44 if (next && current) current.replaceWith(next); 43 45 }; 44 46 45 - export default function AutomationFilters({ q }: Props) { 47 + export default function AutomationFilters({ q, basePath = DEFAULT_BASE_PATH }: Props) { 46 48 const [qValue, setQValue] = useState(q); 47 49 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 48 50 const abortRef = useRef<AbortController | null>(null); ··· 58 60 abortRef.current = ctrl; 59 61 60 62 const prevUrl = window.location.pathname + window.location.search; 61 - const displayUrl = buildDisplayUrl(nextParams); 63 + const displayUrl = buildDisplayUrl(basePath, nextParams); 62 64 const urlChanged = canonical(nextParams.toString()) !== canonical(window.location.search); 63 65 if (urlChanged) history.pushState(null, "", displayUrl); 64 66 ··· 97 99 if (!anchor) return; 98 100 if (!anchor.closest(`#${RESULTS_ID}`)) return; 99 101 const href = anchor.getAttribute("href") ?? ""; 100 - if (!href.startsWith("/automations?")) return; 102 + if (!href.startsWith(`${basePath}?`)) return; 101 103 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; 102 104 e.preventDefault(); 103 105 const qs = href.slice(href.indexOf("?") + 1);
+9 -57
app/routes/automations.tsx
··· 7 7 import { Header } from "../components/Layout/Header/index.js"; 8 8 import { Container } from "../components/Layout/Container/index.js"; 9 9 import { PageHeader } from "../components/Layout/PageHeader/index.js"; 10 - import { AutomationCard } from "../components/AutomationCard/index.js"; 11 - import { Button } from "../components/Button/index.js"; 10 + import { AutomationGallery } from "../components/AutomationGallery/index.js"; 12 11 import AutomationFilters from "../islands/AutomationFilters.js"; 13 12 import ThemeToggle from "../islands/ThemeToggle.js"; 14 - import * as s from "../styles/pages/automations.css.js"; 15 13 16 14 const PAGE_SIZE = 18; 17 15 ··· 48 46 // Swapped in place by the AutomationFilters island. 49 47 // No islands may live inside this subtree — replaceWith drops their listeners. 50 48 const results = ( 51 - <div id="automation-results"> 52 - {featured.length > 0 && ( 53 - <section class={s.featuredSection}> 54 - <h2 class={s.featuredTitle}>Featured</h2> 55 - <div class={s.grid}> 56 - {featured.map((a) => ( 57 - <AutomationCard 58 - key={`${a.did}/${a.rkey}`} 59 - handle={a.handle} 60 - did={a.did} 61 - rkey={a.rkey} 62 - name={a.name} 63 - description={a.description} 64 - lexicon={a.lexicon} 65 - actions={a.actions} 66 - viewerAuthenticated={Boolean(viewer)} 67 - isOwner={viewer?.did === a.did} 68 - featured 69 - /> 70 - ))} 71 - </div> 72 - </section> 73 - )} 74 - 75 - {visible.length > 0 ? ( 76 - <> 77 - <div class={s.grid}> 78 - {visible.map((a) => ( 79 - <AutomationCard 80 - key={`${a.did}/${a.rkey}`} 81 - handle={a.handle} 82 - did={a.did} 83 - rkey={a.rkey} 84 - name={a.name} 85 - description={a.description} 86 - lexicon={a.lexicon} 87 - actions={a.actions} 88 - viewerAuthenticated={Boolean(viewer)} 89 - isOwner={viewer?.did === a.did} 90 - /> 91 - ))} 92 - </div> 93 - {hasMore && ( 94 - <div class={s.loadMoreWrap}> 95 - <Button href={buildQuery({ limit: limit + PAGE_SIZE })} variant="secondary" size="sm"> 96 - Load more 97 - </Button> 98 - </div> 99 - )} 100 - </> 101 - ) : featured.length === 0 ? ( 102 - <p class={s.empty}>No automations match your search. Try a broader search.</p> 103 - ) : null} 104 - </div> 49 + <AutomationGallery 50 + rows={visible} 51 + featured={featured} 52 + viewerDid={viewer?.did} 53 + viewerAuthenticated={Boolean(viewer)} 54 + hasMore={hasMore} 55 + loadMoreHref={buildQuery({ limit: limit + PAGE_SIZE })} 56 + /> 105 57 ); 106 58 107 59 if (isFragment) {
+69 -63
app/routes/u/[handle]/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { eq, inArray, like, count } from "drizzle-orm"; 3 - import { Eye, Zap, Globe } from "../../../icons.js"; 2 + import { and, eq, inArray, like, count } from "drizzle-orm"; 3 + import { Zap, Globe } from "../../../icons.js"; 4 4 import { getSessionUser } from "@/auth/middleware.js"; 5 5 import { resolveHandle } from "@/auth/client.js"; 6 + import { searchAutomations } from "@/automations/search.js"; 6 7 import { db } from "@/db/index.js"; 7 8 import { users, automations } from "@/db/schema.js"; 8 9 import { ··· 15 16 import { Header } from "../../../components/Layout/Header/index.js"; 16 17 import { Container } from "../../../components/Layout/Container/index.js"; 17 18 import { PageHeader } from "../../../components/Layout/PageHeader/index.js"; 18 - import { Badge } from "../../../components/Badge/index.js"; 19 19 import { Button } from "../../../components/Button/index.js"; 20 20 import { Table } from "../../../components/Table/index.js"; 21 - import { InlineCode } from "../../../components/CodeBlock/index.js"; 22 21 import { NsidCode } from "../../../components/NsidCode/index.js"; 23 22 import { Stack } from "../../../components/Layout/Stack/index.js"; 23 + import { AutomationGallery } from "../../../components/AutomationGallery/index.js"; 24 + import AutomationFilters from "../../../islands/AutomationFilters.js"; 24 25 import ThemeToggle from "../../../islands/ThemeToggle.js"; 25 26 import * as s from "../../../styles/pages/profile.css.js"; 26 27 import { centerTextSm } from "../../../styles/utilities.css.js"; 27 28 29 + const PAGE_SIZE = 18; 30 + 28 31 export default createRoute(async (c) => { 29 32 const viewer = await getSessionUser(c); 30 33 const handle = c.req.param("handle")!; 34 + 35 + const q = (c.req.query("q") ?? "").trim().slice(0, 128); 36 + const limit = Math.min(Math.max(Number(c.req.query("limit")) || PAGE_SIZE, PAGE_SIZE), 200); 37 + const isFragment = c.req.header("X-Fragment") === "1"; 31 38 32 39 // Resolve handle → user in DB 33 40 let profileUser = await db.query.users.findFirst({ ··· 44 51 } 45 52 } 46 53 47 - // Fetch automations if user exists 48 - const autos = profileUser 49 - ? await db.query.automations.findMany({ 50 - where: eq(automations.did, profileUser.did), 54 + // Count active automations only — matches what the gallery renders, so the header 55 + // count and the visible cards agree. Inactive ones are surfaced in /dashboard. 56 + const totalAutos = profileUser 57 + ? (( 58 + await db 59 + .select({ count: count() }) 60 + .from(automations) 61 + .where(and(eq(automations.did, profileUser.did), eq(automations.active, true))) 62 + )[0]?.count ?? 0) 63 + : 0; 64 + 65 + const rows = profileUser 66 + ? await searchAutomations(db, { 67 + q, 68 + limit: limit + 1, 69 + did: profileUser.did, 51 70 }) 52 71 : []; 72 + const hasMore = rows.length > limit; 73 + const visible = hasMore ? rows.slice(0, limit) : rows; 74 + 75 + const buildQuery = (overrides: { limit?: number }) => { 76 + const p = new URLSearchParams(); 77 + if (q) p.set("q", q); 78 + if (overrides.limit) p.set("limit", String(overrides.limit)); 79 + const qs = p.toString(); 80 + return qs ? `/u/${handle}?${qs}` : `/u/${handle}`; 81 + }; 53 82 54 83 // Check if handle is a lexicon authority 55 84 const nsidPrefix = handleToNsidPrefix(handle); ··· 71 100 // Count automations subscribed to each lexicon 72 101 const subCounts = new Map<string, number>(); 73 102 if (lexicons.length > 0) { 74 - const rows = await db 103 + const subRows = await db 75 104 .select({ lexicon: automations.lexicon, count: count() }) 76 105 .from(automations) 77 106 .where(inArray(automations.lexicon, lexicons)) 78 107 .groupBy(automations.lexicon); 79 - for (const row of rows) { 108 + for (const row of subRows) { 80 109 subCounts.set(row.lexicon, row.count); 81 110 } 82 111 } 83 112 lexicons.sort((a, b) => (subCounts.get(b) ?? 0) - (subCounts.get(a) ?? 0)); 84 113 85 - // 404 if nothing to show 86 - if (autos.length === 0 && lexicons.length === 0) { 114 + // 404 only when the handle resolves to nothing — neither a known user nor a lexicon 115 + // authority. A registered user with everything paused still gets a real page below. 116 + if (!profileUser && lexicons.length === 0) { 87 117 c.status(404); 88 118 return c.render( 89 119 <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> ··· 101 131 ); 102 132 } 103 133 134 + const emptyMessage = q 135 + ? `@${handle} has no automations matching your search.` 136 + : `@${handle} has no public automations.`; 137 + 138 + const gallery = ( 139 + <AutomationGallery 140 + rows={visible} 141 + viewerDid={viewer?.did} 142 + viewerAuthenticated={Boolean(viewer)} 143 + hasMore={hasMore} 144 + loadMoreHref={buildQuery({ limit: limit + PAGE_SIZE })} 145 + emptyMessage={emptyMessage} 146 + /> 147 + ); 148 + 149 + if (isFragment) { 150 + return c.html(gallery); 151 + } 152 + 104 153 // If viewing own profile, redirect hint 105 154 const isOwnProfile = viewer && profileUser && viewer.did === profileUser.did; 106 155 ··· 111 160 title={`@${profileUser?.handle ?? handle}`} 112 161 description={ 113 162 [ 114 - autos.length > 0 ? `${autos.length} automation${autos.length !== 1 ? "s" : ""}` : "", 163 + totalAutos > 0 ? `${totalAutos} automation${totalAutos !== 1 ? "s" : ""}` : "", 115 164 lexicons.length > 0 116 165 ? `${lexicons.length} lexicon${lexicons.length !== 1 ? "s" : ""}` 117 166 : "", ··· 129 178 /> 130 179 131 180 <Stack gap={6}> 132 - {autos.length > 0 && ( 181 + {totalAutos === 0 && lexicons.length === 0 && ( 182 + <p class={centerTextSm}>@{profileUser?.handle ?? handle} has nothing public yet.</p> 183 + )} 184 + 185 + {totalAutos > 0 && ( 133 186 <section class={s.section}> 134 187 <h2 class={s.sectionTitle}> 135 188 <Zap size={18} /> Automations 136 189 </h2> 137 - <Table> 138 - <thead> 139 - <tr> 140 - <th>Name</th> 141 - <th>Lexicon</th> 142 - <th>Operations</th> 143 - <th>Actions</th> 144 - <th>Status</th> 145 - <th></th> 146 - </tr> 147 - </thead> 148 - <tbody> 149 - {autos.map((auto) => ( 150 - <tr key={auto.uri}> 151 - <td> 152 - <a href={`/u/${handle}/${auto.rkey}`}>{auto.name}</a> 153 - </td> 154 - <td> 155 - <a href={`/lexicons/${auto.lexicon}`}> 156 - <NsidCode>{auto.lexicon}</NsidCode> 157 - </a> 158 - </td> 159 - <td> 160 - {auto.operations.map((op, i) => ( 161 - <> 162 - {i > 0 && ", "} 163 - <InlineCode>{op}</InlineCode> 164 - </> 165 - ))} 166 - </td> 167 - <td> 168 - {auto.actions.length} action{auto.actions.length !== 1 ? "s" : ""} 169 - </td> 170 - <td> 171 - <Badge 172 - variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"} 173 - > 174 - {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 175 - </Badge> 176 - </td> 177 - <td> 178 - <Button href={`/u/${handle}/${auto.rkey}`} variant="ghost" size="sm"> 179 - <Eye size={14} /> View 180 - </Button> 181 - </td> 182 - </tr> 183 - ))} 184 - </tbody> 185 - </Table> 190 + <AutomationFilters q={q} basePath={`/u/${handle}`} /> 191 + {gallery} 186 192 </section> 187 193 )} 188 194
+5
lib/automations/search.ts
··· 22 22 q?: string; 23 23 limit: number; 24 24 excludeUris?: string[]; 25 + did?: string; 25 26 }; 26 27 27 28 export async function searchAutomations( ··· 29 30 params: AutomationSearchParams, 30 31 ): Promise<AutomationSearchResult[]> { 31 32 const conditions = [eq(automations.active, true)]; 33 + 34 + if (params.did) { 35 + conditions.push(eq(automations.did, params.did)); 36 + } 32 37 33 38 if (params.q) { 34 39 // Escape LIKE wildcards with a backslash and declare ESCAPE '\' so '%' and
+285
scripts/rewrite-history.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Rewrite git history on `main`: 4 + - Re-author every commit using the locally configured git user. 5 + - For weekday commits in [10:00, 18:00) local time, randomize the 6 + time-of-day to fall outside that window on the same calendar date. 7 + - Re-sign every commit with the locally configured signing key. 8 + 9 + Run from the repo root. Does NOT push. Creates a backup ref 10 + `refs/backup/main-pre-rewrite` before touching `main`. 11 + """ 12 + 13 + from __future__ import annotations 14 + 15 + import os 16 + import random 17 + import subprocess 18 + import sys 19 + from datetime import datetime, timedelta, timezone 20 + 21 + OLD_NAME = "Hugo" 22 + OLD_EMAIL = "exo-ops@proton.me" 23 + BACKUP_REF = "refs/backup/main-pre-rewrite" 24 + 25 + 26 + def run(cmd: list[str], *, input_bytes: bytes | None = None, check: bool = True) -> subprocess.CompletedProcess: 27 + return subprocess.run(cmd, input=input_bytes, capture_output=True, check=check) 28 + 29 + 30 + def out(cmd: list[str]) -> str: 31 + return run(cmd).stdout.decode("utf-8", errors="replace").rstrip("\n") 32 + 33 + 34 + def fail(msg: str) -> None: 35 + print(f"error: {msg}", file=sys.stderr) 36 + sys.exit(1) 37 + 38 + 39 + def preflight() -> tuple[str, str, str, str]: 40 + branch = out(["git", "symbolic-ref", "--short", "HEAD"]) 41 + if branch != "main": 42 + fail(f"current branch is '{branch}', expected 'main'") 43 + 44 + porcelain = out(["git", "status", "--porcelain"]) 45 + if porcelain: 46 + fail("working tree is dirty; commit or stash first") 47 + 48 + name = out(["git", "config", "user.name"]) 49 + email = out(["git", "config", "user.email"]) 50 + signingkey = out(["git", "config", "user.signingkey"]) 51 + gpgformat = out(["git", "config", "gpg.format"]) or "openpgp" 52 + 53 + if not name or not email or not signingkey: 54 + fail("user.name, user.email, and user.signingkey must all be configured") 55 + if name == OLD_NAME or email == OLD_EMAIL: 56 + fail(f"new identity must differ from old ({OLD_NAME} <{OLD_EMAIL}>)") 57 + 58 + print(f"new identity: {name} <{email}>") 59 + print(f"signing key: {signingkey} ({gpgformat})") 60 + 61 + # Backup ref 62 + existing_backup = subprocess.run( 63 + ["git", "rev-parse", "--verify", "--quiet", BACKUP_REF], 64 + capture_output=True, 65 + ) 66 + if existing_backup.returncode == 0: 67 + print(f"warning: {BACKUP_REF} already exists; not overwriting") 68 + else: 69 + run(["git", "update-ref", BACKUP_REF, "refs/heads/main"]) 70 + print(f"backup ref: {BACKUP_REF} -> {out(['git', 'rev-parse', BACKUP_REF])[:12]}") 71 + 72 + return name, email, signingkey, gpgformat 73 + 74 + 75 + def parse_iso(s: str) -> datetime: 76 + # Python 3.11+ handles trailing Z and offsets fine 77 + return datetime.fromisoformat(s) 78 + 79 + 80 + def in_window(dt: datetime) -> bool: 81 + return dt.weekday() <= 4 and 10 <= dt.hour < 18 82 + 83 + 84 + def pick_outside_time(rng: random.Random) -> tuple[int, int, int]: 85 + """Pick (h, m, s) outside [10:00, 18:00). Window: [0, 10) ∪ [18, 24).""" 86 + pool_seconds = 10 * 3600 + 6 * 3600 # 16h available 87 + n = rng.randrange(pool_seconds) 88 + if n < 10 * 3600: 89 + total = n # 0..10h 90 + else: 91 + total = (18 * 3600) + (n - 10 * 3600) 92 + h, rem = divmod(total, 3600) 93 + m, s = divmod(rem, 60) 94 + return h, m, s 95 + 96 + 97 + def compute_new_dates(commits: list[tuple[str, datetime]]) -> list[datetime]: 98 + """commits: list of (sha, original_author_dt). Returns aligned new dts.""" 99 + n = len(commits) 100 + new_dts: list[datetime | None] = [None] * n 101 + 102 + # Anchors: positions where the original date is kept. 103 + for i, (_sha, dt) in enumerate(commits): 104 + if not in_window(dt): 105 + new_dts[i] = dt 106 + 107 + # Fill shifted positions. 108 + for i, (sha, dt) in enumerate(commits): 109 + if new_dts[i] is not None: 110 + continue 111 + 112 + rng = random.Random(sha) 113 + date = dt.date() 114 + tz = dt.tzinfo 115 + 116 + # Find prev bound (max of new_dts[<i] and start of date). 117 + prev_bound = None 118 + for j in range(i - 1, -1, -1): 119 + if new_dts[j] is not None: 120 + prev_bound = new_dts[j] 121 + break 122 + 123 + # Find next anchor (smallest fixed timestamp at j>i). 124 + next_bound = None 125 + for j in range(i + 1, n): 126 + if new_dts[j] is not None: 127 + next_bound = new_dts[j] 128 + break 129 + 130 + day_start = datetime(date.year, date.month, date.day, 0, 0, 0, tzinfo=tz) 131 + day_end = day_start + timedelta(days=1) - timedelta(seconds=1) 132 + 133 + lo = day_start 134 + if prev_bound is not None and prev_bound > lo: 135 + # Must be strictly after prev; clamp to same date. 136 + lo = max(lo, prev_bound + timedelta(seconds=1)) 137 + 138 + hi = day_end 139 + if next_bound is not None and next_bound < hi: 140 + hi = min(hi, next_bound - timedelta(seconds=1)) 141 + 142 + if lo > hi: 143 + # Order would be violated; fall back to prev+1s and warn. 144 + chosen = (prev_bound + timedelta(seconds=1)) if prev_bound else day_start 145 + print( 146 + f"warn: tight window for {sha[:12]} on {date}; falling back to {chosen.isoformat()}", 147 + file=sys.stderr, 148 + ) 149 + new_dts[i] = chosen 150 + continue 151 + 152 + # Try up to 50 times to land outside the window. 153 + chosen = None 154 + for _ in range(50): 155 + h, m, s = pick_outside_time(rng) 156 + cand = datetime(date.year, date.month, date.day, h, m, s, tzinfo=tz) 157 + if lo <= cand <= hi: 158 + chosen = cand 159 + break 160 + if chosen is None: 161 + # No valid outside-window time; pick midpoint, accept whatever. 162 + mid = lo + (hi - lo) / 2 163 + chosen = mid.replace(microsecond=0) 164 + print( 165 + f"warn: no outside-window slot for {sha[:12]} on {date}; using {chosen.isoformat()}", 166 + file=sys.stderr, 167 + ) 168 + new_dts[i] = chosen 169 + 170 + return [d for d in new_dts] # type: ignore[misc] 171 + 172 + 173 + def collect_commits() -> list[tuple[str, datetime]]: 174 + raw = out(["git", "log", "--reverse", "--pretty=format:%H%x09%aI", "main"]) 175 + result = [] 176 + for line in raw.splitlines(): 177 + sha, iso = line.split("\t") 178 + result.append((sha, parse_iso(iso))) 179 + return result 180 + 181 + 182 + def get_tree(sha: str) -> str: 183 + return out(["git", "rev-parse", f"{sha}^{{tree}}"]) 184 + 185 + 186 + def get_message(sha: str) -> bytes: 187 + cp = subprocess.run( 188 + ["git", "log", "-1", "--format=%B", sha], capture_output=True, check=True 189 + ) 190 + msg = cp.stdout 191 + # `git log %B` appends a trailing newline beyond the commit's stored 192 + # message terminator; strip exactly one trailing LF if present, then 193 + # ensure exactly one trailing LF (matches git's normalization). 194 + if msg.endswith(b"\n"): 195 + msg = msg[:-1] 196 + if not msg.endswith(b"\n"): 197 + msg = msg + b"\n" 198 + return msg 199 + 200 + 201 + def parents_of(sha: str) -> list[str]: 202 + p = out(["git", "rev-list", "--parents", "-n", "1", sha]).split() 203 + return p[1:] 204 + 205 + 206 + def replay( 207 + commits: list[tuple[str, datetime]], 208 + new_dts: list[datetime], 209 + new_name: str, 210 + new_email: str, 211 + ) -> str: 212 + mapping: dict[str, str] = {} 213 + last_new = None 214 + for (orig_sha, _orig_dt), new_dt in zip(commits, new_dts): 215 + tree = get_tree(orig_sha) 216 + msg = get_message(orig_sha) 217 + parents = parents_of(orig_sha) 218 + 219 + env = os.environ.copy() 220 + date_str = new_dt.strftime("%Y-%m-%dT%H:%M:%S%z") 221 + # Insert colon in tz offset for ISO 8601 strict form (git accepts both) 222 + env.update( 223 + { 224 + "GIT_AUTHOR_NAME": new_name, 225 + "GIT_AUTHOR_EMAIL": new_email, 226 + "GIT_AUTHOR_DATE": date_str, 227 + "GIT_COMMITTER_NAME": new_name, 228 + "GIT_COMMITTER_EMAIL": new_email, 229 + "GIT_COMMITTER_DATE": date_str, 230 + } 231 + ) 232 + 233 + cmd = ["git", "commit-tree", "-S", tree] 234 + for p in parents: 235 + mapped = mapping.get(p) 236 + if mapped is None: 237 + fail(f"parent {p[:12]} of {orig_sha[:12]} not yet mapped") 238 + cmd += ["-p", mapped] 239 + 240 + cp = subprocess.run(cmd, input=msg, env=env, capture_output=True) 241 + if cp.returncode != 0: 242 + sys.stderr.write(cp.stderr.decode("utf-8", errors="replace")) 243 + fail(f"git commit-tree failed for {orig_sha[:12]}") 244 + new_sha = cp.stdout.decode().strip() 245 + mapping[orig_sha] = new_sha 246 + last_new = new_sha 247 + 248 + if last_new is None: 249 + fail("no commits replayed") 250 + return last_new 251 + 252 + 253 + def main() -> None: 254 + repo_root = out(["git", "rev-parse", "--show-toplevel"]) 255 + os.chdir(repo_root) 256 + 257 + new_name, new_email, _key, _fmt = preflight() 258 + 259 + commits = collect_commits() 260 + print(f"commits to rewrite: {len(commits)}") 261 + 262 + new_dts = compute_new_dates(commits) 263 + 264 + shifted = sum(1 for (_, dt), nd in zip(commits, new_dts) if dt != nd) 265 + print(f"timestamps shifted: {shifted} / {len(commits)}") 266 + 267 + print("type 'yes' to proceed: ", end="", flush=True) 268 + if sys.stdin.readline().strip().lower() != "yes": 269 + fail("aborted by user") 270 + 271 + new_tip = replay(commits, new_dts, new_name, new_email) 272 + old_tip = out(["git", "rev-parse", "main"]) 273 + run(["git", "update-ref", "refs/heads/main", new_tip]) 274 + 275 + print() 276 + print(f"old tip: {old_tip}") 277 + print(f"new tip: {new_tip}") 278 + print(f"backup: {BACKUP_REF}") 279 + print() 280 + print("Verify, then push with:") 281 + print(" git push --force-with-lease origin main") 282 + 283 + 284 + if __name__ == "__main__": 285 + main()