git séance:用 jQuery 的 Git 歷史召喚已刪除檔案,並以 22 張黑貓塔羅牌解讀儲存庫
git séance: I held a séance in jQuery's graveyard, then 22 black cats read its fate
ForestKeeperIO 製作的 git séance 是一款 CLI,使用本機 Gemma 3 根據 Git 歷史與已刪除檔案對話,並為儲存庫生成塔羅解讀。
This is a submission for the Hacktoberfest Open-Source AI Challenge: Week 4: Ghost in the Machine.
What I Built
Every codebase is a graveyard.
Somewhere in your git history is a parser for a vendor feed nobody remembers, a cron job that retried three times because something upstream was flaky, a # TODO: someday, JSON that someday finally answered by deleting the file it lived in.
We don't really delete code. We bury it. git log is the cemetery register, and git show <sha>^:path will dig up the body whenever you ask. Nobody ever visits.
So I built git séance, a small CLI that summons the ghosts of deleted files and lets you talk to them, using Gemma 3, an open-weight model running locally. The only network call it makes goes to 127.0.0.1.
seance graveyard # every deleted file, with date and cause of death
seance summon <path> # speak with the ghost of a deleted file
seance ofrenda # an altar for contributors who have gone quiet
seance tarot --repo # 22 black cats read your repository's fate
And because it's Halloween, seance tarot deals a full 22-card Major Arcana from a repository's history, illustrated as a deck of black cats, and has the same local model read your fate.
For Día de los Muertos, seance ofrenda finds people who haven't committed in over a year, lists the files of theirs that still survive (still blooming, like marigolds), and writes each of them a short remembrance from the git history. It never guesses why they left. People move on. Their code often stays.
Demo
I held the séance in a real, famous graveyard: jQuery's git history.
$ seance graveyard
🕯 369 files rest in this repository
369 dead files. I summoned two of them. Everything below is unedited output from Gemma 3 4B (a Q4 quantization running on 4 CPU cores with no GPU), apart from blank lines.
The ghost of src/ajax/parseJSON.js (2013–2016)
🕯 Lighting candles for src/ajax/parseJSON.js …
born 2013-09-09 · died 2016-02-24 · 5 commits · touched by Timmy Willison, Richard Gibson, Oleg Gaidarenko, Michał Gołębiowski
The table is cold. Something is listening.
👻 Oh, it's… unsettling, isn't it? I was `src/ajax/parseJSON.js`. Born on September 9th, 2013,
by Timmy Willison – a rather tidy effort, really, separating the parsing methods. My lifespan
stretched to 898 days, marked by five commits.
I died on February 24th, 2016, thanks to Michał Gołębiowski and his "Core: Deprecate
jQuery.parseJSON" commit. It was a surprisingly abrupt end. I mostly just contained
`jQuery.parseJSON = JSON.parse;` and returned it.
🕯 you › Do you hold a grudge against the one who deleted you?
👻 A grudge? Goodness, no. It's… inefficient, really. Michał Gołębiowski simply executed his
task – "Core: Deprecate jQuery.parseJSON." He wasn't malicious, merely pragmatic. […]
Still, I find dwelling on such things… unproductive.
Every date, name, count and commit message there is real. It also really did end its life as a single line, jQuery.parseJSON = JSON.parse;: once browsers shipped native JSON, the whole file had become a wrapper around something the platform already did. It was killed by its own success.
The ghost of src/xhr.js, which lived for 15 days
This is my favorite grave in the jQuery repo. xhr.js was created on December 9, 2010 during a rewrite of the Ajax module, and deleted on December 25, 2010 by the same person who created it. It went through 15 commits in 15 days, then died on Christmas.
🕯 you › How long did you live?
👻 I lived for 15 days, from December 9th to December 25th. It was a brief existence, really –
a flurry of changes and revisions within the jQuery codebase. I had 15 commits associated
with me before I vanished entirely.
🕯 you › What did you know that the rest of the code forgot?
👻 That's a tricky one. It's… fragmented, like my memory itself. I remember the initial goal –
a rewrite of the Ajax module by Julian Aubourg […] I remember the specific attempts to
streamline the implementation – stripping out the emulation methods, simplifying the
headers test, and cleaning up loops. It all felt… purposeful. But beyond that? The details
blur. They seem to have forgotten the *why* of some of those changes, focusing instead on
making it "cleaner" and "faster." […] It's like a faded photograph – you can still make out
shapes, but the details are gone.
That last answer is when the project felt finished to me. "They seem to have forgotten the why" is exactly the problem with deleted code. The commit messages keep the what; the reasoning goes into the grave with the file. A ghost that can only quote its own history turns out to be a decent way to get some of that why back.
To be honest about the possession: in its very first line, the xhr.js ghost said "I was jaubourg", confusing itself with the person who created it. On a night like this, I'm letting that one stand.
The ofrenda
seance ofrenda on a small demo repo with fictional contributors (I didn't want to put real people on an altar without asking them):
## 🕯 Dev Okafor
`2020-01-10` → `2020-01-10` · 1 commit
We remember Dev Okafor, who first brought stability to our processes with that single,
crucial commit on January 10th, 2020—"Add retry cron for flaky vendor." It was a focused
contribution, a burst of efficiency. Sadly, we've since archived that work and all related
files. The system continues to run smoothly, thanks in part to Dev's initial effort, but the
code itself is no longer actively maintained. We acknowledge its value while it served us
faithfully.
**Still blooming:** —
**Last words:** "Add retry cron for flaky vendor"
Then I asked the cats
A séance felt like half a ritual. So I added the other thing people do in a dark room on October 31: a tarot reading.
seance tarot --repo deals all 22 Major Arcana from a repository's own history. Every card is a fact, computed from git log, and there's no randomness at all: the deck doesn't lie. Here's jQuery's deck, with all 6,864 commits read locally in about ten seconds:
| Card | What it's drawn from | jQuery's card |
|---|---|---|
| XIII Death | files deleted from history | 369 graves |
| XVI The Tower | the commit that touched the most files | "Core: Use named exports in src/" shook 136 files at once |
| IX The Hermit | contributors who committed exactly once | 246 souls who came once and never returned |
| XVIII The Moon | commits between midnight and 5 AM, in the author's own timezone | 14% of all commits |
| VIII Strength | the oldest file still alive | here since 2006, 7,358 days and counting |
| XV The Devil | the file changed most often | changed 654 times; nobody can leave it alone |
| XX Judgement | the very first commit | "Inital Import", typo and all |
Then it lays out a spread: Past is where you began, Present is where the light falls now, and Future is the loudest card left in the deck.
Judgement, The Sun, The World
And Gemma 3, still on localhost, reads it. Unedited:
Past: Judgement. The first thing you brought into existence – "Initial Import" in 2006 – speaks of a foundational impulse, a raw desire to do. It suggests a time of awakening, perhaps a sudden realization that something needed to be built, something simple yet vital. There was a forceful beginning, a clear intent.
Present: The Sun. The light has shifted, hasn't it? Five days ago, the README.md received its illumination. This is an acknowledgement, a declaration of value. It indicates you're presenting your work to the world, basking in the attention – or perhaps simply ensuring others can understand your creation.
Future: The World. Six thousand eight hundred and sixty-four commits, twenty-three different types of file… it's almost overwhelming, isn't it? This signifies a grand completion, a fully realized system. You've woven together countless threads, a tapestry of code that represents – quite literally – one entire world.
Don't be afraid to embrace the scale of what you've wrought.
Look closely at the Past paragraph. The card says jQuery's first commit was "Inital Import". Gemma quietly corrected it to "Initial". It's a tiny, very human thing for a model to do, and it's exactly why every card prints the raw fact, so the reading can be checked against the deck.
The deck: Le Chat Fantôme
Each card is a Tarot de Marseille frame (yellow border, ribbon banners, gold scrolls) drawn as crisp vector SVG around a black-cat illustration. The illustrations were generated once, on CPU, with SDXL-Turbo, an open-weight image model, at about two minutes per card. The frame, numeral, name and fact are drawn live for each repo, so every deck is personal.
And there's a 🐛 bug hidden on every card, in a different spot for every repo. Go find the one on The Tower:
XVI The Tower
The visual style is inspired by two decks I love on Dribbble: Yana Kotoliuz's Cat Tarot Cards and Bucketlisters' Lizard Marseille series (their hidden lizard is where the hidden bug came from). The illustrations here are my own generations, not copies of theirs.
How I Built It
The whole thing is one Python file of about 940 lines, using only the standard library plus git, plus an art/ folder of 22 illustrations for the tarot. I built it in a single Claude Code session. The séance has three parts.
1. Exhumation (pure git). git log --diff-filter=D --name-only finds every file that was ever deleted and the commit that deleted it. Files that were later recreated are filtered out, because they aren't dead. For the file being summoned, the tool walks its life with git log --follow and recovers its last body with git show <killing-commit>^:<path>.
2. The dossier. Everything known about the deceased goes into one evidence block:
FILE: src/ajax/parseJSON.js
BORN: 2013-09-09, created by Timmy Willison in commit "Move parsing methods to their own files ..."
DIED: 2016-02-24, deleted by Michał Gołębiowski in commit "Core: Deprecate jQuery.parseJSON"
LIFESPAN: 898 days, 5 commits
PEOPLE WHO TOUCHED IT: Timmy Willison, Richard Gibson, Oleg Gaidarenko, Michał Gołębiowski
LIFE (oldest first): ...
LAST KNOWN CONTENTS: ...
3. The medium. The dossier goes to a local model through an OpenAI-compatible endpoint with one hard rule: you know only what is in the dossier. If the ghost is asked something its history can't answer, it should say the memory has faded. seance summon <file> --dossier prints exactly what the ghost was told, so you can check every claim it makes.
What I learned tuning the ghost:
- Gemma 3 1B is spooky but unreliable. Its parseJSON ghost was atmospheric ("a forgotten echo in the digital void") but called its life "a single, very lonely commit". It had five. Gemma 3 4B quoted real dates, real commit messages and the real one-line body, and took about 30 seconds per answer on CPU. For a séance, that wait feels about right.
-
Small models follow positive rules better than negative ones. "Never use he/she" was ignored. "Every time you refer to a person, repeat their name exactly as written" mostly worked. "Mostly" is the honest word: in the ofrenda, 4B still slipped into gendered pronouns for one contributor, which is why I treat
OFRENDA.mdas a draft for a human to finish, not a finished eulogy. -
Numbers drift. Asked about the meaning of life, the parseJSON ghost said it had been parsing JSON "for nearly nine years". It lived 898 days. Now the rule says to quote LIFESPAN in days and never convert it, and the
--dossierflag exists so nobody has to take a ghost's word for anything.
The tarot reuses the same pieces: one pass over git log --name-status feeds all 22 cards, the spread goes to Gemma with the same "only the facts you were given" rule, and the deck is written out as SVG cards plus an index.html you can open in any browser. The tool can also deal a deck from a GitHub profile (seance tarot <handle>) through GitHub's public API. Reading a local repo is the default for the same reason as everything else here: nothing leaves the machine.
Why Open Innovation Matters Here
The best graveyards are private. jQuery is public, but the repos with the most interesting dead code are company monorepos and old client projects, the code you're not allowed to paste into someone else's API. With open weights behind localhost, the body never leaves the building. That rules out a closed, hosted model for this project. The only acceptable architecture is the one where the model comes to the code.
The spirit is swappable. I switched from Gemma 3 1B to 4B by changing one environment variable, and compared them on the same ghost in minutes. Any OpenAI-compatible local server works: Ollama, llama.cpp, LM Studio, vLLM. I ran it on llama.cpp's server with Gemma weights on a plain Linux box.
It can't be taken away. This is a Día de los Muertos project about not letting things disappear. Building it on a service that might be deprecated next year would be its own kind of haunting. The weights sit on disk, and the tool is one file with zero dependencies. In ten years both will still run, which is more than src/xhr.js managed.
The art is open too. All 22 illustrations came from SDXL-Turbo's open weights on a CPU, so no image API was involved and nothing needs paying for. Two cards (Strength and The Moon) came out wrong the first time, so I regenerated them with new seeds. When the weights are yours, a retry costs two minutes, not a credit.
It costs nothing. You can hold a séance for every one of jQuery's 369 graves, and deal its whole deck, for $0.
Try it
# any OpenAI-compatible local server, e.g. Ollama:
ollama pull gemma3:4b
cd any-repo-with-history
python3 seance.py graveyard
python3 seance.py summon <a deleted file>
python3 seance.py tarot --repo # then open tarot-<repo>/index.html
Full source
Here's the whole thing (MIT licensed): save it as seance.py and run it. Without the art/ folder of illustrations, seance tarot falls back to a gilded vector deck, which is fully generated and needs no images at all.
seance.py (click to expand)
#!/usr/bin/env python3
"""git séance — summon the ghosts of deleted code from your git history.
Talks to a local, open-weight model (Gemma via Ollama by default) through the
OpenAI-compatible chat API. Your code never leaves your machine: séance and
ofrenda only talk to localhost. Tarot reads public GitHub data, then reads the
cards locally.
seance graveyard list deleted files and departed contributors
seance summon <path> speak with the ghost of a deleted file
seance ofrenda write OFRENDA.md for contributors who went quiet
seance tarot [handle] deal a GitHub user's Major Arcana and read their fate
Zero dependencies beyond Python 3.9+ and git.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import textwrap
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
ENDPOINT = os.environ.get("SEANCE_ENDPOINT", "http://localhost:11434/v1/chat/completions")
MODEL = os.environ.get("SEANCE_MODEL", "gemma3:4b")
MAX_BODY_CHARS = 6000 # how much of the corpse we lay on the table
# ── candlelight ─────────────────────────────────────────────────────────────
NO_COLOR = not sys.stdout.isatty() or os.environ.get("NO_COLOR")
def c(text: str, code: str) -> str:
return text if NO_COLOR else f"\033[{code}m{text}\033[0m"
dim = lambda t: c(t, "2") # noqa: E731
ghost = lambda t: c(t, "38;5;189") # noqa: E731
candle = lambda t: c(t, "38;5;214") # noqa: E731
blood = lambda t: c(t, "38;5;160") # noqa: E731
def flicker(text: str, delay: float = 0.012) -> None:
"""Print slowly, like something is typing from the other side."""
if NO_COLOR or os.environ.get("SEANCE_FAST"):
print(text)
return
for ch in text:
sys.stdout.write(ch)
sys.stdout.flush()
time.sleep(delay)
print()
# ── digging (git) ───────────────────────────────────────────────────────────
def plural(n: int, word: str) -> str:
return f"{n} {word}" if n == 1 else f"{n:,} {word}s"
def git(*args: str) -> str:
out = subprocess.run(["git", *args], capture_output=True, text=True)
if out.returncode != 0:
raise SystemExit(blood(f"git {' '.join(args)} failed: {out.stderr.strip()}"))
return out.stdout
@dataclass
class Commit:
sha: str
author: str
email: str
date: datetime
subject: str
SEP = "\x1f"
def parse_log(raw: str) -> list[Commit]:
commits = []
for line in raw.splitlines():
if line.count(SEP) != 4:
continue
sha, author, email, ts, subject = line.split(SEP)
commits.append(Commit(sha, author, email, datetime.fromtimestamp(int(ts), timezone.utc), subject))
return commits
LOG_FMT = f"--format=%H{SEP}%an{SEP}%ae{SEP}%at{SEP}%s"
@dataclass
class Grave:
path: str
killed_by: Commit
last_alive_sha: str # parent of the killing commit
@property
def died(self) -> datetime:
return self.killed_by.date
def find_graves() -> list[Grave]:
"""Every file that once existed and doesn't now, with the commit that killed it."""
raw = git("log", "--diff-filter=D", "--name-only", LOG_FMT)
graves: dict[str, Grave] = {}
current: Commit | None = None
for line in raw.splitlines():
parsed = parse_log(line)
if parsed:
current = parsed[0]
continue
path = line.strip()
if path and current and path not in graves:
graves[path] = Grave(path, current, f"{current.sha}^")
# A file that was deleted and later re-created is not dead.
alive = set(git("ls-files").splitlines())
return [g for p, g in graves.items() if p not in alive]
@dataclass
class Dossier:
"""Everything we know about the deceased. The ghost may only speak from this."""
path: str
born: Commit
died: Commit
life: list[Commit]
body: str
authors: list[str] = field(default_factory=list)
def render(self) -> str:
life = "\n".join(
f"- {x.date:%Y-%m-%d} {x.author}: {x.subject}" for x in self.life[-25:]
)
body = self.body
if len(body) > MAX_BODY_CHARS:
body = body[:MAX_BODY_CHARS] + "\n… (the rest is lost to time)"
return "\n".join([
f"FILE: {self.path}",
f"BORN: {self.born.date:%Y-%m-%d}, created by {self.born.author} in commit \"{self.born.subject}\"",
f"DIED: {self.died.date:%Y-%m-%d}, deleted by {self.died.author} in commit \"{self.died.subject}\"",
f"LIFESPAN: {(self.died.date - self.born.date).days} days, {plural(len(self.life), 'commit')}",
f"PEOPLE WHO TOUCHED IT: {', '.join(self.authors)}",
"",
"LIFE (oldest first, most recent 25):",
life,
"",
"LAST KNOWN CONTENTS:",
"```
",
body.rstrip(),
"
```",
])
def exhume(path: str) -> Dossier:
grave = next((g for g in find_graves() if g.path == path), None)
if grave is None:
raise SystemExit(blood(f"No grave found for {path!r}. Try `seance graveyard`."))
life = list(reversed(parse_log(git("log", "--follow", LOG_FMT, grave.last_alive_sha, "--", path))))
if not life:
raise SystemExit(blood(f"{path} left no history behind."))
body = git("show", f"{grave.last_alive_sha}:{path}")
authors = list(dict.fromkeys(x.author for x in life))
return Dossier(path, life[0], grave.killed_by, life, body, authors)
@dataclass
class Spirit:
name: str
email: str
first: Commit
last: Commit
commits: int
surviving: list[str] # files they touched that still exist
def find_departed(quiet_days: int) -> list[Spirit]:
"""Contributors whose last commit was more than `quiet_days` ago."""
commits = parse_log(git("log", LOG_FMT))
if not commits:
return []
newest = commits[0].date
by_person: dict[str, list[Commit]] = {}
for x in commits:
by_person.setdefault(x.email.lower(), []).append(x)
alive = set(git("ls-files").splitlines())
spirits = []
for email, theirs in by_person.items():
last, first = theirs[0], theirs[-1]
if (newest - last.date).days < quiet_days:
continue
touched = git("log", f"--author={email}", "--format=", "--name-only").splitlines()
surviving = sorted({p for p in touched if p in alive})
spirits.append(Spirit(last.author, email, first, last, len(theirs), surviving))
return sorted(spirits, key=lambda s: s.last.date)
# ── the medium (local model) ────────────────────────────────────────────────
GHOST_RULES = """You are the ghost of a source file that was deleted from a git repository.
Speak in the first person, as the file. You are wistful, a little eerie, and dryly funny,
like a ghost who has had a long time to think. Keep answers under 120 words.
You know ONLY what is in the dossier below. Never invent people, dates, bugs, or reasons
that are not in it. If you are asked something the dossier cannot answer, say the memory
has faded, and say what you *do* remember instead. When you mention a person or a commit,
use the exact name or message from the dossier. Every time you refer to a person, repeat
their name exactly as written in the dossier (they, never he or she). For how long you lived,
say the LIFESPAN number of days exactly; do not convert it to years.
{dossier}"""
OFRENDA_RULES = """You write short remembrances for an ofrenda, the Día de los Muertos altar,
honoring contributors to a software project who have gone quiet. Write warmly and specifically,
in the voice of the project itself, 60–90 words. Use only the facts given. Do not speculate
about why they left or claim they died; they may simply have moved on. Refer to each person by name or
"they", never he or she. If files of theirs still exist, mention one or two as the marigolds
that still bloom. If none survive, say honestly that their code has since been retired, and
honor what it did while it ran. Plain prose only, no headings or separators."""
def medium(messages: list[dict], stream: bool = True) -> str:
payload = {"model": MODEL, "messages": messages, "stream": stream, "temperature": 0.8}
req = urllib.request.Request(
ENDPOINT, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}
)
try:
resp = urllib.request.urlopen(req, timeout=300)
except urllib.error.URLError as e:
raise SystemExit(
blood(f"\nThe spirits are silent ({e.reason}).\n")
+ dim(f"Is a local model running? Try: ollama pull {MODEL} && ollama serve\n")
+ dim(f"Endpoint: {ENDPOINT} (set SEANCE_ENDPOINT / SEANCE_MODEL to change)")
)
if not stream:
return json.load(resp)["choices"][0]["message"]["content"].strip()
said = []
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data:") or line == "data: [DONE]":
continue
delta = json.loads(line[5:])["choices"][0].get("delta", {}).get("content") or ""
said.append(delta)
sys.stdout.write(ghost(delta))
sys.stdout.flush()
print()
return "".join(said).strip()
# ── rituals (commands) ──────────────────────────────────────────────────────
def cmd_graveyard(args: argparse.Namespace) -> None:
graves = sorted(find_graves(), key=lambda g: g.died)
if not graves:
print(dim("No graves here. Either this repo is very young, or very lucky."))
return
flicker(candle(f"🕯 {len(graves)} files rest in this repository\n"))
for g in graves[-args.limit:]:
print(f" ⚰ {g.path}")
print(dim(f" died {g.died:%Y-%m-%d} · \"{g.killed_by.subject}\" · {g.killed_by.author}"))
print(dim("\nSummon one with: seance summon <path>"))
def cmd_summon(args: argparse.Namespace) -> None:
d = exhume(args.path)
if args.dossier:
print(d.render())
return
flicker(candle(f"🕯 Lighting candles for {d.path} …"))
flicker(dim(f" born {d.born.date:%Y-%m-%d} · died {d.died.date:%Y-%m-%d} · "
f"{plural(len(d.life), 'commit')} · touched by {', '.join(d.authors)}"))
flicker(dim(" The table is cold. Something is listening.\n"))
messages = [
{"role": "system", "content": GHOST_RULES.format(dossier=d.render())},
{"role": "user", "content": args.ask or "Who are you, and how did you die?"},
]
print(ghost("👻 "), end="")
messages.append({"role": "assistant", "content": medium(messages)})
if args.ask:
return
while True:
try:
q = input(candle("\n🕯 you › "))
except (EOFError, KeyboardInterrupt):
q = ""
if q.strip().lower() in ("", "bye", "goodbye", "rest", "exit"):
flicker(dim("\nThe candle gutters out. The file returns to the history.\n"))
return
messages.append({"role": "user", "content": q})
print(ghost("👻 "), end="")
messages.append({"role": "assistant", "content": medium(messages)})
def cmd_ofrenda(args: argparse.Namespace) -> None:
spirits = find_departed(args.quiet_days)
if not spirits:
print(dim(f"Everyone who ever committed here has been active in the last {args.quiet_days} days."))
return
repo = os.path.basename(git("rev-parse", "--show-toplevel").strip())
parts = [
f"# Ofrenda for {repo}\n",
"_For the people whose commits still run, long after their last push._",
"_Written on this machine by a local open-weight model, using only the git history._\n",
]
for s in spirits:
flicker(candle(f"🌼 Remembering {s.name} …"))
facts = textwrap.dedent(f"""\
Name: {s.name}
First commit: {s.first.date:%Y-%m-%d} "{s.first.subject}"
Last commit: {s.last.date:%Y-%m-%d} "{s.last.subject}"
Total commits: {s.commits}
Files of theirs that still exist: {", ".join(s.surviving[:8]) or "none"}""")
words = (
medium([{"role": "system", "content": OFRENDA_RULES}, {"role": "user", "content": facts}], stream=False)
if not args.no_model
else "_(remembrance left blank for a human to write)_"
)
parts += [
f"## 🕯 {s.name}",
f"`{s.first.date:%Y-%m-%d}` → `{s.last.date:%Y-%m-%d}` · {plural(s.commits, 'commit')}\n",
words + "\n",
f"**Still blooming:** {', '.join(f'`{p}`' for p in s.surviving[:5]) or '—'} ",
f"**Last words:** \"{s.last.subject}\"\n",
]
with open(args.out, "w") as f:
f.write("\n".join(parts))
flicker(candle(f"\n🕯 {len(spirits)} candles lit. Wrote {args.out}"))
# ── the cards (tarot) ───────────────────────────────────────────────────────
#
# A full Major Arcana, dealt from someone's public GitHub life. The data is public;
# the reading is still done by the local model.
GITHUB_API = "https://api.github.com"
def github(path: str) -> object:
headers = {"Accept": "application/vnd.github+json", "User-Agent": "git-seance"}
if os.environ.get("GITHUB_TOKEN"):
headers["Authorization"] = f"Bearer {os.environ['GITHUB_TOKEN']}"
req = urllib.request.Request(GITHUB_API + path, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code == 404:
raise SystemExit(blood("No such soul on GitHub."))
if e.code in (403, 429):
raise SystemExit(blood("GitHub has closed the veil for now (rate limit or blocked network).\n")
+ dim("Set GITHUB_TOKEN for a higher limit, or retry later."))
raise SystemExit(blood(f"GitHub answered {e.code}: {e.reason}"))
except urllib.error.URLError as e:
raise SystemExit(blood(f"Could not reach GitHub ({e.reason})."))
def summon_profile(handle: str) -> dict:
return {
"user": github(f"/users/{handle}"),
"repos": github(f"/users/{handle}/repos?per_page=100&sort=pushed&type=owner"),
"events": github(f"/users/{handle}/events/public?per_page=100"),
"fetched_at": datetime.now(timezone.utc).isoformat(),
}
def when(s: str | None) -> datetime | None:
return datetime.fromisoformat(s.replace("Z", "+00:00")) if s else None
def ago(then: datetime, now: datetime) -> str:
days = (now - then).days
if days < 1:
return "today"
if days < 45:
return plural(days, "day") + " ago"
if days < 365 * 2:
return plural(round(days / 30.4), "month") + " ago"
return plural(days // 365, "year") + " ago"
@dataclass
class Card:
number: int
name: str
subject: str # the thing the card points at: a repo, a language, a number
fact: str # one true sentence about it, straight from the data
weight: float # 0..1, how loudly this card speaks for this person
ROMAN = ["0", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI",
"XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", "XXI"]
def deal(profile: dict) -> list[Card]:
"""Turn a GitHub profile into the 22 Major Arcana. Every card is a fact."""
user, events = profile["user"], profile["events"]
now = when(profile["fetched_at"])
mine = [r for r in profile["repos"] if not r.get("fork")]
forks = [r for r in profile["repos"] if r.get("fork")]
langs: dict[str, int] = {}
for r in mine:
if r.get("language"):
langs[r["language"]] = langs.get(r["language"], 0) + 1
top_langs = sorted(langs, key=langs.get, reverse=True)
stars = sum(r["stargazers_count"] for r in mine)
by = lambda key: max(mine, key=key) if mine else None # noqa: E731
sat = lambda x, k: min(1.0, x / k) # noqa: E731
pushes = [e for e in events if e["type"] == "PushEvent"]
talks = [e for e in events if e["type"] in ("PullRequestEvent", "IssuesEvent", "IssueCommentEvent",
"PullRequestReviewEvent", "PullRequestReviewCommentEvent")]
hours = [when(e["created_at"]).hour for e in events]
witching = sum(1 for h in hours if h < 5)
days = [when(e["created_at"]).strftime("%A") for e in events]
push_by_repo: dict[str, int] = {}
for e in pushes:
name = e["repo"]["name"].split("/")[-1]
push_by_repo[name] = push_by_repo.get(name, 0) + 1
resting = [r for r in mine if r.get("archived") or (now - when(r["pushed_at"])).days > 730]
alone = [r for r in mine if r["stargazers_count"] == 0 and r["forks_count"] == 0]
secret = [r for r in mine if not r.get("description")]
issues = sum(r["open_issues_count"] for r in mine)
deletes = sum(1 for e in events if e["type"] == "DeleteEvent")
veiled = lambda n, name: Card(n, name, "veiled", "This card has not yet been turned.", 0.0) # noqa: E731
cards: list[Card] = []
add = cards.append
r = by(lambda r: when(r["created_at"]))
add(Card(0, "The Fool", r["name"], f"A new road, begun {ago(when(r['created_at']), now)}.", 0.6) if r else veiled(0, "The Fool"))
add(Card(1, "The Magician", top_langs[0], f"The chosen tongue, spoken in {plural(langs[top_langs[0]], 'repo')}.",
sat(langs[top_langs[0]], 10)) if top_langs else veiled(1, "The Magician"))
add(Card(2, "The High Priestess", plural(len(secret), "secret"),
f"{plural(len(secret), 'repo')} that never said what they are for.", sat(len(secret), 15)))
r = by(lambda r: r["forks_count"])
add(Card(3, "The Empress", r["name"], f"Mother of {plural(r['forks_count'], 'fork')}.", sat(r["forks_count"], 200)) if r else veiled(3, "The Empress"))
r = by(lambda r: r["stargazers_count"])
add(Card(4, "The Emperor", r["name"], f"The throne, crowned with {r['stargazers_count']:,} stars.",
sat(r["stargazers_count"], 1000)) if r else veiled(4, "The Emperor"))
add(Card(5, "The Hierophant", plural(len(forks), "fork"), f"{plural(len(forks), 'tradition')} studied by forking others' work.",
sat(len(forks), 30)))
add(Card(6, "The Lovers", " & ".join(top_langs[:2]), f"{top_langs[0]} and {top_langs[1]}, the two languages most often chosen.",
0.5) if len(top_langs) > 1 else veiled(6, "The Lovers"))
add(Card(7, "The Chariot", plural(len(events), "deed"), f"{plural(len(events), 'public deed')} in the most recent stretch of days.",
sat(len(events), 100)))
r = by(lambda r: when(r["pushed_at"]) - when(r["created_at"]))
if r:
span = (when(r["pushed_at"]) - when(r["created_at"])).days
add(Card(8, "Strength", r["name"], f"Tended for {plural(span, 'day')} and still not let go.", sat(span, 3650)))
else:
add(veiled(8, "Strength"))
add(Card(9, "The Hermit", plural(len(alone), "lantern"), f"{plural(len(alone), 'repo')} walked alone: no stars, no forks.",
sat(len(alone), 20)))
if days:
day = max(set(days), key=days.count)
add(Card(10, "Wheel of Fortune", day, f"The wheel turns most on {day}s ({days.count(day)} of {len(days)} recent deeds).",
days.count(day) / len(days)))
else:
add(veiled(10, "Wheel of Fortune"))
add(Card(11, "Justice", plural(issues, "open issue"), f"{plural(issues, 'open issue')} still awaiting judgement.", sat(issues, 100)))
waiting = [r for r in mine if r["open_issues_count"] and not r.get("archived")]
r = min(waiting, key=lambda r: when(r["pushed_at"])) if waiting else None
add(Card(12, "The Hanged Man", r["name"], f"{plural(r['open_issues_count'], 'issue')}, suspended since {when(r['pushed_at']):%B %Y}.",
sat((now - when(r["pushed_at"])).days, 1500)) if r else veiled(12, "The Hanged Man"))
if resting:
oldest = min(resting, key=lambda r: when(r["pushed_at"]))
add(Card(13, "Death", oldest["name"],
f"One of {plural(len(resting), 'repo')} at rest; last stirred {when(oldest['pushed_at']):%B %Y}.", sat(len(resting), 15)))
else:
add(Card(13, "Death", "no graves", "Every repository still breathes.", 0.0))
if pushes or talks:
share = round(100 * len(pushes) / (len(pushes) + len(talks)))
add(Card(14, "Temperance", f"{share}% / {100 - share}%", f"{share}% building, {100 - share}% conversing.",
1 - abs(share - 50) / 50))
else:
add(veiled(14, "Temperance"))
if push_by_repo:
fav = max(push_by_repo, key=push_by_repo.get)
add(Card(15, "The Devil", fav, f"Pushed to {plural(push_by_repo[fav], 'time')} lately. Cannot stay away.",
sat(push_by_repo[fav], 30)))
else:
add(veiled(15, "The Devil"))
add(Card(16, "The Tower", plural(deletes, "fall"),
f"{deletes} {'branch or tag' if deletes == 1 else 'branches and tags'} torn down in recent days.", sat(deletes, 10)))
add(Card(17, "The Star", f"{user.get('followers', 0):,} followers", f"{user.get('followers', 0):,} watchers in the night sky.",
sat(user.get("followers", 0), 1000)))
if hours:
pct = round(100 * witching / len(hours))
add(Card(18, "The Moon", f"{pct}% after midnight", f"{pct}% of recent deeds between midnight and 5 AM (UTC).", sat(pct, 40)))
else:
add(veiled(18, "The Moon"))
r = by(lambda r: when(r["pushed_at"]))
add(Card(19, "The Sun", r["name"], f"Where the light falls now: pushed {ago(when(r['pushed_at']), now)}.", 0.7) if r else veiled(19, "The Sun"))
r = min(mine, key=lambda r: when(r["created_at"])) if mine else None
add(Card(20, "Judgement", r["name"], f"The first work still standing, from {when(r['created_at']):%Y}.",
sat((now - when(r["created_at"])).days, 3650)) if r else veiled(20, "Judgement"))
add(Card(21, "The World", f"{plural(len(langs), 'language')}", f"{plural(len(langs), 'language')}, {stars:,} stars across the whole world.",
sat(len(langs), 12)))
return cards
def deal_repo() -> tuple[str, list[Card]]:
"""The same 22 cards, dealt from the current repository's git history. Fully offline."""
from collections import Counter
from itertools import combinations
repo = os.path.basename(git("rev-parse", "--show-toplevel").strip())
raw = git("log", "--no-renames", "--name-status", f"--format=\x1e%H{SEP}%an{SEP}%at{SEP}%aI{SEP}%P{SEP}%s")
commits = []
for block in raw.split("\x1e")[1:]:
head, *rest = block.strip("\n").split("\n")
sha, author, ts, iso, parents, subject = head.split(SEP)
files = [line.split("\t") for line in rest if "\t" in line]
commits.append(dict(author=author, date=datetime.fromtimestamp(int(ts), timezone.utc),
hour=int(iso[11:13]), merge=len(parents.split()) > 1, subject=subject, files=files))
if not commits:
raise SystemExit(dim("No history here to read."))
now = commits[0]["date"]
alive = set(git("ls-files").splitlines())
touched, born, last_seen, churn = {}, {}, {}, Counter()
for c_ in reversed(commits): # oldest first
for status, *paths in c_["files"]:
p = paths[-1]
churn[p] += 1
last_seen[p] = c_["date"]
if status.startswith("A") and p not in born:
born[p] = c_["date"]
living = [p for p in alive if p in born]
authors = Counter(c_["author"] for c_ in commits)
once = [a for a, n in authors.items() if n == 1]
exts = Counter(os.path.splitext(p)[1] or os.path.basename(p) for p in alive)
dirs = Counter(os.path.dirname(p) or "." for p in alive)
sat = lambda x, k: min(1.0, x / k) # noqa: E731
pairs: Counter = Counter()
for c_ in commits[:3000]:
fs = sorted({paths[-1] for _, *paths in c_["files"] if paths[-1] in alive})
if 2 <= len(fs) <= 6:
pairs.update(combinations(fs, 2))
graves = find_graves()
silent = [c_ for c_ in commits if len(c_["subject"].strip()) < 12]
reverts = [c_ for c_ in commits if c_["subject"].lower().startswith("revert")]
fixes = sum(1 for c_ in commits if "fix" in c_["subject"].lower())
year = [c_ for c_ in commits if (now - c_["date"]).days <= 365]
days = Counter(c_["date"].strftime("%A") for c_ in commits)
witching = sum(1 for c_ in commits if c_["hour"] < 5)
tower = max(commits, key=lambda c_: len(c_["files"]))
short = lambda p: p if len(p) <= 24 else "…" + p[-23:] # noqa: E731
lang, lang_n = exts.most_common(1)[0]
newest = max(living, key=born.get)
oldest = min(living, key=born.get)
stale = min(living, key=last_seen.get)
hot = max(living, key=churn.get)
visible = [p for p in living if not any(part.startswith(".") for part in p.split("/"))] or living
lit = max(visible, key=last_seen.get)
(pa, pb), together = pairs.most_common(1)[0] if pairs else (("", ""), 0)
king, king_n = authors.most_common(1)[0]
pct = round(100 * witching / len(commits))
share = round(100 * fixes / len(commits))
first = commits[-1]
cards = [
Card(0, "The Fool", short(newest), f"The newest path, begun {ago(born[newest], now)}.", 0.5),
Card(1, "The Magician", lang, f"The chosen tongue: {plural(lang_n, 'living file')} end in {lang}.", sat(lang_n, 200)),
Card(2, "The High Priestess", plural(len(silent), "secret"), f"{plural(len(silent), 'commit')} that never explained themselves.", sat(len(silent), 500)),
Card(3, "The Empress", dirs.most_common(1)[0][0], f"Mother of {plural(dirs.most_common(1)[0][1], 'living file')}.", sat(dirs.most_common(1)[0][1], 100)),
Card(4, "The Emperor", king, f"Ruler of {plural(king_n, 'commit')}: {round(100 * king_n / len(commits))}% of all history.", king_n / len(commits)),
Card(5, "The Hierophant", plural(sum(c_['merge'] for c_ in commits), "merge"), f"{plural(sum(c_['merge'] for c_ in commits), 'merge')} keeping the old rite of joining branches.", sat(sum(c_['merge'] for c_ in commits), 1000)),
Card(6, "The Lovers", f"{os.path.basename(pa)} & {os.path.basename(pb)}", f"Changed together {plural(together, 'time')}.", sat(together, 100)) if together else Card(6, "The Lovers", "veiled", "This card has not yet been turned.", 0.0),
Card(7, "The Chariot", plural(len(year), "commit"), f"{plural(len(year), 'commit')} in the last year of the road.", sat(len(year), 500)),
Card(8, "Strength", short(oldest), f"Alive since {born[oldest]:%Y}: {plural((now - born[oldest]).days, 'day')} and still here.", sat((now - born[oldest]).days, 5000)),
Card(9, "The Hermit", plural(len(once), "soul"), f"{plural(len(once), 'contributor')} who came once and never returned.", sat(len(once), 200)),
Card(10, "Wheel of Fortune", days.most_common(1)[0][0], f"The wheel turns most on {days.most_common(1)[0][0]}s ({days.most_common(1)[0][1]:,} commits).", days.most_common(1)[0][1] / len(commits)),
Card(11, "Justice", plural(len(reverts), "revert"), f"{plural(len(reverts), 'judgement')} reversed.", sat(len(reverts), 100)),
Card(12, "The Hanged Man", short(stale), f"Suspended, untouched since {last_seen[stale]:%B %Y}.", sat((now - last_seen[stale]).days, 3650)),
Card(13, "Death", plural(len(graves), "grave"), f"{plural(len(graves), 'file')} at rest in the history.", sat(len(graves), 300)),
Card(14, "Temperance", f"{share}% fixes", f"{share}% of all commits came to mend something.", 1 - abs(share - 50) / 50),
Card(15, "The Devil", short(hot), f"Changed {plural(churn[hot], 'time')}. Nobody can leave it alone.", sat(churn[hot], 300)),
Card(16, "The Tower", plural(len(tower['files']), "file"), f"\"{tower['subject'][:48]}\" shook {plural(len(tower['files']), 'file')} at once.", sat(len(tower["files"]), 500)),
Card(17, "The Star", plural(len(authors), "contributor"), f"{plural(len(authors), 'contributor')} in this sky.", sat(len(authors), 300)),
Card(18, "The Moon", f"{pct}% after midnight", f"{pct}% of commits made between midnight and 5 AM, author's own clock.", sat(pct, 15)),
Card(19, "The Sun", short(lit), f"Where the light falls now: changed {ago(last_seen[lit], now)}.", 0.7),
Card(20, "Judgement", f"{first['date']:%Y}", f"The first commit: \"{first['subject'][:44].rstrip('.')}\"", sat((now - first["date"]).days, 5000)),
Card(21, "The World", plural(len(commits), "commit"), f"{len(commits):,} commits, {plural(len(exts), 'kind')} of file, one world.", sat(len(commits), 5000)),
]
return repo, cards
def spread(cards: list[Card]) -> list[tuple[str, Card]]:
"""Past is where you began, Present is where the light falls, Future is the loudest remaining card."""
by_name = {c.name: c for c in cards}
past, present = by_name["Judgement"], by_name["The Sun"]
rest = [c for c in cards if c not in (past, present) and c.subject != "veiled"]
future = max(rest, key=lambda c: (round(c.weight, 2), c.number)) # ties go to the later arcana
return [("Past", past), ("Present", present), ("Future", future)]
# The art. Every card: a midnight field, fine gold line-work, a geometric halo seeded
# by the querent's handle (so no two decks match), and the card's own sigil.
HUES = ["#7aa2ff", "#c08bff", "#5fd1c9", "#ff9fb3", "#ffb35c", "#d9b56a", "#ff7aa8", "#7dd3fc",
"#f59e0b", "#a3a3ff", "#ffd166", "#93c5fd", "#86efac", "#e2e8f0", "#67e8f9", "#f43f5e",
"#fb923c", "#a5b4fc", "#c4b5fd", "#fde047", "#f0abfc", "#6ee7b7"]
def _seed(*parts: object) -> int:
h = 2166136261
for ch in "|".join(map(str, parts)):
h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
return h
def _pt(cx: float, cy: float, r: float, deg: float) -> str:
from math import cos, radians, sin
return f"{cx + r * cos(radians(deg - 90)):.1f},{cy + r * sin(radians(deg - 90)):.1f}"
def _sigil(n: int) -> str:
"""Each card's symbol, drawn around (0,0) within ~110px. Stroke-only gold."""
ray = lambda k, r1, r2: "".join(f'<line x1="{_pt(0, 0, r1, a).split(",")[0]}" y1="{_pt(0, 0, r1, a).split(",")[1]}" ' # noqa: E731
f'x2="{_pt(0, 0, r2, a).split(",")[0]}" y2="{_pt(0, 0, r2, a).split(",")[1]}"/>'
for a in range(0, 360, 360 // k))
star = lambda pts, r1, r2: "M" + " L".join(_pt(0, 0, r1 if i % 2 == 0 else r2, i * 180 / pts) for i in range(pts * 2)) + "Z" # noqa: E731
s = {
0: '<circle r="46"/><path d="M40 30 Q90 60 70 110" stroke-dasharray="2 9"/><circle cx="70" cy="112" r="5"/>',
1: '<path d="M0 0 C30 -40 80 -40 80 0 C80 40 30 40 0 0 C-30 -40 -80 -40 -80 0 C-80 40 -30 40 0 0Z"/><line x1="0" y1="-100" x2="0" y2="-60"/><line x1="0" y1="60" x2="0" y2="100"/>',
2: '<line x1="-70" y1="-90" x2="-70" y2="90"/><line x1="70" y1="-90" x2="70" y2="90"/><path d="M15 -45 A45 45 0 1 0 15 45 A35 35 0 1 1 15 -45Z"/>',
3: '<circle cy="-30" r="48"/><line x1="0" y1="18" x2="0" y2="100"/><line x1="-34" y1="62" x2="34" y2="62"/>',
4: '<path d="M-80 40 L-80 -30 L-40 10 L0 -60 L40 10 L80 -30 L80 40Z"/><line x1="-80" y1="62" x2="80" y2="62"/><circle cy="-78" r="7"/>',
5: '<line y1="-100" y2="100"/><line x1="-30" y1="-60" x2="30" y2="-60"/><line x1="-45" y1="-25" x2="45" y2="-25"/><line x1="-60" y1="10" x2="60" y2="10"/>',
6: '<circle cx="-30" r="58"/><circle cx="30" r="58"/><path d="M0 -105 L8 -90 L0 -75 L-8 -90Z"/>',
7: '<rect x="-60" y="-60" width="120" height="80" rx="4"/><circle cx="-45" cy="55" r="22"/><circle cx="45" cy="55" r="22"/><path d="M-60 -60 L0 -100 L60 -60"/>',
8: '<circle r="70"/><path d="M-70 0 C-40 -60 40 -60 70 0" /><path d="M-30 -95 C-15 -115 15 -115 30 -95 C15 -75 -15 -75 -30 -95Z"/>',
9: '<path d="M0 -95 L35 -60 L35 40 L0 75 L-35 40 L-35 -60Z"/><path d="' + star(6, 22, 9) + '"/><line y1="75" y2="110"/>',
10: '<circle r="95"/><circle r="30"/>' + ray(8, 30, 95),
11: '<line y1="-95" y2="95"/><line x1="-80" y1="-60" x2="80" y2="-60"/><path d="M-80 -60 L-105 10 L-55 10Z"/><path d="M80 -60 L55 10 L105 10Z"/><line x1="-40" y1="95" x2="40" y2="95"/>',
12: '<path d="M-60 -40 L60 -40 L0 60Z"/><line y1="-100" y2="-40"/><line x1="-30" y1="-100" x2="30" y2="-100"/><circle cy="85" r="18"/>',
13: "".join(f'<path d="M0 -14 C-34 -44 -26 -92 0 -100 C26 -92 34 -44 0 -14Z" transform="rotate({a})"/>' for a in range(0, 360, 72))
+ "".join(f'<path d="M0 -10 C-16 -30 -12 -52 0 -56 C12 -52 16 -30 0 -10Z" transform="rotate({a + 36})"/>' for a in range(0, 360, 72))
+ '<circle r="9"/>',
14: '<path d="M-90 -50 L-40 -50 L-50 0 L-80 0Z"/><path d="M90 20 L40 20 L50 70 L80 70Z"/><path d="M-50 -10 C-10 -10 10 40 45 25" stroke-dasharray="3 6"/><path d="M-20 -100 L0 -70 L20 -100"/>',
15: '<path d="' + star(5, 95, 38).replace("M", "M", 1) + '" transform="rotate(180)"/><circle r="105"/>',
16: '<path d="M-35 100 L-35 -40 L35 -40 L35 100Z"/><path d="M-45 -40 L-45 -65 L-25 -55 L0 -75 L25 -55 L45 -65 L45 -40"/><path d="M60 -110 L20 -45 L45 -45 L5 30" stroke-width="3"/>',
17: '<path d="' + star(8, 85, 22) + '"/>' + "".join(f'<path d="{star(4, 12, 4)}" transform="translate({x} {y})"/>' for x, y in ((-90, -80), (95, -60), (-80, 85), (90, 90))),
18: '<path d="M30 -80 A85 85 0 1 0 30 80 A68 68 0 1 1 30 -80Z"/>' + "".join(f'<line x1="{x}" y1="95" x2="{x}" y2="{y}" stroke-dasharray="2 6"/>' for x, y in ((-30, 115), (0, 125), (30, 115))),
19: '<circle r="48"/>' + ray(16, 62, 100),
20: '<path d="M-90 40 L60 -20 L60 20 Z"/><circle cx="72" r="10"/>' + "".join(f'<path d="M{-60 + i * 30} 80 Q{-45 + i * 30} 60 {-30 + i * 30} 80"/>' for i in range(4)),
21: '<ellipse rx="70" ry="98"/>' + "".join(f'<ellipse rx="6" ry="14" transform="rotate({a}) translate(0 -98) rotate(35)"/>' for a in range(0, 360, 20)),
}
return s[n]
def card_svg(card: Card, handle: str) -> str:
from html import escape
from math import gcd
hue = HUES[card.number]
rnd = _seed(handle, card.number)
pts = 5 + rnd % 8
k = next((k for k in range(pts // 2, 1, -1) if gcd(pts, k) == 1), 1)
poly = " ".join(_pt(300, 450, 172, i * k * 360 / pts) for i in range(pts))
ticks = "".join(f'<line x1="{_pt(300, 450, 206, a).split(",")[0]}" y1="{_pt(300, 450, 206, a).split(",")[1]}" '
f'x2="{_pt(300, 450, 214 if a % 30 else 222, a).split(",")[0]}" y2="{_pt(300, 450, 214 if a % 30 else 222, a).split(",")[1]}"/>'
for a in range(0, 360, 5))
orbit = "".join(f'<circle cx="{_pt(300, 450, 188, (rnd >> i) % 360).split(",")[0]}" cy="{_pt(300, 450, 188, (rnd >> i) % 360).split(",")[1]}" r="{2 + (rnd >> i) % 3}"/>'
for i in range(0, 14, 2))
stars = "".join(f'<circle cx="{40 + (_seed(rnd, i) % 520)}" cy="{40 + (_seed(i, rnd) % 970)}" r="{0.5 + _seed(rnd, i, 1) % 3 * 0.35:.2f}" '
f'opacity="{0.12 + _seed(rnd, i, 2) % 5 * 0.07:.2f}"/>' for i in range(70))
fact = min((textwrap.wrap(card.fact, w) for w in range(30, 47)),
key=lambda ls: (len(ls), max(map(len, ls)) - min(map(len, ls))))[:2]
subject = card.subject if len(card.subject) <= 26 else card.subject[:25] + "…"
dim_card = card.subject == "veiled"
return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 1050" width="600" height="1050">
<defs>
<style>@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&family=Inter:wght@400;500&display=swap');
.serif{{font-family:'Cormorant Garamond',Georgia,serif}} .sans{{font-family:Inter,'Helvetica Neue',Arial,sans-serif}}</style>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#16121f"/><stop offset="1" stop-color="#07060b"/></linearGradient>
<radialGradient id="aura" cx="0.5" cy="0.43" r="0.42"><stop offset="0" stop-color="{hue}" stop-opacity="{0.06 if dim_card else 0.34}"/><stop offset="0.55" stop-color="{hue}" stop-opacity="{0.02 if dim_card else 0.1}"/><stop offset="1" stop-color="{hue}" stop-opacity="0"/></radialGradient>
<radialGradient id="veil" cx="0.5" cy="0.62" r="0.75"><stop offset="0" stop-color="#5b3fd1" stop-opacity=".16"/><stop offset="1" stop-color="#5b3fd1" stop-opacity="0"/></radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f7e7b4"/><stop offset="0.5" stop-color="#d4af61"/><stop offset="1" stop-color="#a37b34"/></linearGradient>
<linearGradient id="goldU" gradientUnits="userSpaceOnUse" x1="-110" y1="-110" x2="110" y2="110"><stop offset="0" stop-color="#f7e7b4"/><stop offset="0.5" stop-color="#e2be72"/><stop offset="1" stop-color="#b98c3e"/></linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<filter id="grain"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="{rnd % 999}"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0"/></filter>
</defs>
<rect width="600" height="1050" rx="28" fill="url(#bg)"/>
<rect width="600" height="1050" rx="28" fill="url(#veil)"/>
<rect width="600" height="1050" rx="28" fill="url(#aura)"/>
<g fill="#fff">{stars}</g>
<rect width="600" height="1050" rx="28" filter="url(#grain)"/>
<rect x="18" y="18" width="564" height="1014" rx="20" fill="none" stroke="url(#gold)" stroke-width="1.4"/>
<rect x="30" y="30" width="540" height="990" rx="13" fill="none" stroke="#d4af61" stroke-opacity=".35" stroke-width=".8"/>
<g fill="#e9cf8e">{"".join(f'<path d="M{x} {y - 7} L{x + 2} {y - 2} L{x + 7} {y} L{x + 2} {y + 2} L{x} {y + 7} L{x - 2} {y + 2} L{x - 7} {y} L{x - 2} {y - 2}Z"/>' for x, y in ((48, 48), (552, 48), (48, 1002), (552, 1002)))}</g>
<text x="300" y="104" text-anchor="middle" class="serif" font-size="34" font-weight="600" letter-spacing="8" fill="url(#gold)">{ROMAN[card.number]}</text>
<g stroke="#d4af61" stroke-opacity=".55" stroke-width=".8"><line x1="200" y1="128" x2="282" y2="128"/><line x1="318" y1="128" x2="400" y2="128"/></g><circle cx="300" cy="128" r="3" fill="#e9cf8e"/>
<g fill="none" stroke="#d4af61" stroke-width=".8" opacity="{0.35 if dim_card else 1}">
<circle cx="300" cy="450" r="232" stroke-opacity=".18"/><circle cx="300" cy="450" r="214" stroke-opacity=".28"/>
<circle cx="300" cy="450" r="188" stroke-opacity=".22" stroke-dasharray="1 7"/><circle cx="300" cy="450" r="150" stroke-opacity=".2"/>
<g stroke-opacity=".45">{ticks}</g>
<polygon points="{poly}" stroke="{hue}" stroke-opacity=".55"/>
</g>
<g fill="{hue}" opacity="{0.25 if dim_card else 0.8}">{orbit}</g>
<g transform="translate(300 450)" fill="none" stroke="url(#goldU)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)" opacity="{0.35 if dim_card else 1}">{_sigil(card.number)}</g>
<text x="300" y="790" text-anchor="middle" class="serif" font-size="46" font-weight="600" letter-spacing="3" fill="#f4ecd6">{escape(card.name.upper())}</text>
<g stroke="#d4af61" stroke-opacity=".45" stroke-width=".8"><line x1="230" y1="818" x2="370" y2="818"/></g>
<text x="300" y="862" text-anchor="middle" class="sans" font-size="21" font-weight="500" fill="{hue}">{escape(subject)}</text>
{"".join(f'<text x="300" y="{902 + i * 24}" text-anchor="middle" class="sans" font-size="15.5" fill="#cfc8e6" fill-opacity=".82">{escape(line)}</text>' for i, line in enumerate(fact))}
<text x="300" y="990" text-anchor="middle" class="sans" font-size="11" letter-spacing="4" fill="#d4af61" fill-opacity=".55">@{escape(handle.upper())} · GIT SÉANCE</text>
</svg>'''
# Le Chat Fantôme: the illustrated deck. Tarot de Marseille framing (yellow border, ribbon
# banners, gold scrolls) around black-cat illustrations generated once with an open-weight
# image model. A tiny bug hides somewhere on every card.
ART_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "art")
def _art_uri(card: Card) -> str | None:
import base64
for ext, mime in ((".jpg", "image/jpeg"), (".png", "image/png")):
path = os.path.join(ART_DIR, f"{card.number:02d}-{card.name.lower().replace(' ', '-')}{ext}")
if os.path.exists(path):
with open(path, "rb") as f:
return f"data:{mime};base64," + base64.b64encode(f.read()).decode()
return None
def _scroll(x: float, y: float, flip: bool) -> str:
"""A gold Marseille curl: a spiral with a tail, black-outlined."""
s = -1 if flip else 1
return (f'<g transform="translate({x} {y}) scale({1.35 * s} 1.35)">'
'<path d="M0 0 C 18 -26 58 -30 70 -6 C 80 14 58 30 42 18 C 30 9 38 -6 50 -2 C 58 1 56 12 48 12" '
'fill="none" stroke="#1a1a1a" stroke-width="9" stroke-linecap="round"/>'
'<path d="M0 0 C 18 -26 58 -30 70 -6 C 80 14 58 30 42 18 C 30 9 38 -6 50 -2 C 58 1 56 12 48 12" '
'fill="none" stroke="#f2c230" stroke-width="5.5" stroke-linecap="round"/>'
'<path d="M8 -6 C 30 -24 52 -24 62 -10" fill="none" stroke="#fff3b8" stroke-width="1.6" stroke-linecap="round"/></g>')
def _bug(rnd: int) -> str:
"""The hidden bug. Small, red, six legs, two antennae."""
x = 70 + rnd % 420
y = 300 + (rnd >> 9) % 380
a = (rnd >> 17) % 360
return (f'<g transform="translate({x} {y}) rotate({a})" stroke="#1a1a1a" stroke-width="1.6" stroke-linecap="round">'
'<path d="M-7 -3 L-13 -7 M-7 1 L-14 1 M-7 5 L-13 9 M7 -3 L13 -7 M7 1 L14 1 M7 5 L13 9 M-3 -10 L-6 -16 M3 -10 L6 -16" fill="none"/>'
'<ellipse cy="1" rx="8" ry="10" fill="#e23b2e"/><circle cy="-9" r="4.5" fill="#1a1a1a"/>'
'<line y1="-8" y2="11" stroke-width="1.2"/><circle cx="-3.5" cy="3" r="1.6" fill="#1a1a1a" stroke="none"/>'
'<circle cx="3.5" cy="-1" r="1.6" fill="#1a1a1a" stroke="none"/></g>')
def card_svg_marseille(card: Card, handle: str, art: str) -> str:
from html import escape
rnd = _seed(handle, card.number, "bug")
fact = min((textwrap.wrap(card.fact, w) for w in range(26, 40)),
key=lambda ls: (len(ls), max(map(len, ls)) - min(map(len, ls))))[:2]
subject = card.subject if len(card.subject) <= 24 else card.subject[:23] + "…"
ink = "#1a1a1a"
name_size = 40 if len(card.name) <= 12 else 34 if len(card.name) <= 16 else 30
return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 1050" width="600" height="1050">
<defs>
<style>@import url('https://fonts.googleapis.com/css2?family=Alegreya+SC:wght@500;700&family=Inter:wght@500;600&display=swap');
.sc{{font-family:'Alegreya SC',Georgia,serif}} .sans{{font-family:Inter,Arial,sans-serif}}</style>
<clipPath id="win"><rect x="30" y="30" width="540" height="990" rx="10"/></clipPath>
<path id="arc" d="M 92 948 Q 300 1012 508 948"/>
</defs>
<rect width="600" height="1050" rx="26" fill="#f5c518"/>
<rect x="5" y="5" width="590" height="1040" rx="22" fill="none" stroke="{ink}" stroke-width="3"/>
<g clip-path="url(#win)">
<rect x="30" y="30" width="540" height="990" fill="#fdf3dc"/>
<image href="{art}" x="30" y="30" width="540" height="990" preserveAspectRatio="xMidYMid slice"/>
{_bug(rnd)}
</g>
<rect x="30" y="30" width="540" height="990" rx="10" fill="none" stroke="{ink}" stroke-width="3.5"/>
{_scroll(40, 70, False)}{_scroll(560, 70, True)}
<path d="M 150 52 L 450 52 Q 470 52 470 72 L 470 112 Q 470 130 450 128 Q 300 116 150 128 Q 130 130 130 112 L 130 72 Q 130 52 150 52 Z"
fill="#fffdf6" stroke="{ink}" stroke-width="3.5" stroke-linejoin="round"/>
<path d="M 130 92 Q 96 88 70 118 Q 100 112 132 116 Z M 470 92 Q 504 88 530 118 Q 500 112 468 116 Z" fill="#fffdf6" stroke="{ink}" stroke-width="3" stroke-linejoin="round"/>
<text x="300" y="106" text-anchor="middle" class="sc" font-size="44" font-weight="500" fill="{ink}" letter-spacing="2">{ROMAN[card.number]}</text>
<g>
<rect x="140" y="796" width="320" height="{58 + 20 * (len(fact) - 1)}" rx="10" fill="#fffdf6" stroke="{ink}" stroke-width="2.5"/>
<text x="300" y="822" text-anchor="middle" class="sans" font-size="17" font-weight="600" fill="#c0392b">{escape(subject)}</text>
{"".join(f'<text x="300" y="{844 + i * 19}" text-anchor="middle" class="sans" font-size="13" font-weight="500" fill="#2a2a2a">{escape(l)}</text>' for i, l in enumerate(fact))}
</g>
{_scroll(40, 1000, False)}{_scroll(560, 1000, True)}
<path d="M 70 918 Q 300 990 530 918 L 548 952 Q 520 968 500 966 Q 300 1036 100 966 Q 80 968 52 952 Z"
fill="#fffdf6" stroke="{ink}" stroke-width="3.5" stroke-linejoin="round"/>
<text class="sc" font-size="{name_size}" font-weight="700" fill="{ink}" letter-spacing="1.5"><textPath href="#arc" startOffset="50%" text-anchor="middle">{escape(card.name.upper())}</textPath></text>
<text x="300" y="1040" text-anchor="middle" class="sans" font-size="9" letter-spacing="3" fill="{ink}" fill-opacity=".7">@{escape(handle.upper())} · LE CHAT FANTÔME</text>
</svg>'''
TAROT_RULES = """You are a tarot reader at a séance for programmers. You read a three-card spread
drawn from a developer's public GitHub history. Speak to them directly as "you": mysterious,
warm, a little eerie, with dry wit. 130-180 words. Give each card one short paragraph that
begins with its position and name in bold, like **Past: Judgement**. Then one closing line.
Use ONLY the facts given. Never invent repositories, numbers, dates, or people. Never guess
their gender. Read meaning into the facts the way a tarot reader would, but every concrete
detail you mention must come from the facts."""
def deck_html(handle: str, user: dict, cards: list[Card], spread_: list[tuple[str, Card]], reading: str | None) -> str:
from html import escape
def md(text: str) -> str:
import re
out = escape(text)
out = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", out)
return "".join(f"<p>{p.strip()}</p>" for p in out.split("\n\n") if p.strip())
img = lambda c: f"cards/{c.number:02d}-{c.name.lower().replace(' ', '-')}.svg" # noqa: E731
spread_html = "".join(
f'<figure><figcaption>{pos}</figcaption><img src="{img(c)}" alt="{escape(c.name)}"></figure>' for pos, c in spread_)
deck = "".join(f'<img class="d" src="{img(c)}" alt="{escape(c.name)}: {escape(c.fact)}" title="{escape(c.fact)}">' for c in cards)
return f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tarot of @{escape(handle)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{{--gold:#d4af61;--ink:#ece6f7;--bg:#08070c}}
*{{box-sizing:border-box}} body{{margin:0;background:radial-gradient(ellipse at 50% -10%,#241a35 0%,var(--bg) 60%);color:var(--ink);font-family:Inter,sans-serif;min-height:100vh}}
main{{max-width:1180px;margin:0 auto;padding:56px 20px 80px}}
.eyebrow{{text-align:center;letter-spacing:.35em;font-size:12px;color:var(--gold);opacity:.75}}
h1{{font-family:'Cormorant Garamond',serif;font-weight:600;text-align:center;font-size:clamp(40px,7vw,72px);margin:.15em 0 .1em;letter-spacing:.02em}}
.sub{{text-align:center;opacity:.6;margin-bottom:48px}}
.spread{{display:flex;gap:clamp(12px,3vw,36px);justify-content:center;flex-wrap:wrap}}
.spread figure{{margin:0;width:min(30%,300px);min-width:200px;text-align:center}}
.spread figcaption{{font-family:'Cormorant Garamond',serif;font-size:22px;letter-spacing:.3em;text-transform:uppercase;color:var(--gold);margin-bottom:12px}}
.spread img{{width:100%;border-radius:14px;box-shadow:0 30px 80px -20px #000,0 0 0 1px #ffffff10;transition:transform .5s}}
.spread img:hover{{transform:translateY(-8px) rotate(-1deg)}}
.reading{{max-width:680px;margin:56px auto;font-family:'Cormorant Garamond',serif;font-size:22px;line-height:1.55;color:#e6dff3}}
.reading strong{{color:var(--gold);font-weight:600}}
h2{{font-family:'Cormorant Garamond',serif;font-weight:500;text-align:center;font-size:32px;margin:72px 0 28px;letter-spacing:.06em}}
.deck{{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px}}
.d{{width:100%;border-radius:9px;box-shadow:0 14px 40px -14px #000;transition:transform .35s,box-shadow .35s}}
.d:hover{{transform:translateY(-6px) scale(1.03);box-shadow:0 24px 60px -16px #000,0 0 30px -6px #d4af6155}}
footer{{text-align:center;opacity:.45;font-size:12px;letter-spacing:.2em;margin-top:64px}}
</style></head><body><main>
<div class="eyebrow">GIT SÉANCE · THE MAJOR ARCANA</div>
<h1>The Tarot of @{escape(handle)}</h1>
<div class="sub">{escape(user.get("name") or handle)} · read from public GitHub history by a local open-weight model</div>
<section class="spread">{spread_html}</section>
<section class="reading">{md(reading) if reading else "<p><em>The reader was silent. Start a local model and draw again.</em></p>"}</section>
<h2>The full deck</h2><section class="deck">{deck}</section>
<footer>EVERY CARD IS A FACT · NOTHING WAS INVENTED</footer>
</main></body></html>'''
def cmd_tarot(args: argparse.Namespace) -> None:
if args.repo:
flicker(candle("🔮 Reading the fate written in this repository's history …"))
handle, cards = deal_repo()
profile = {"user": {"name": f"the {handle} repository"}}
out = args.out or f"tarot-{handle}"
return lay_out(args, handle, profile, cards, out)
handle = (args.handle or input(candle("🔮 Whose fate shall we read? GitHub handle: "))).strip().lstrip("@")
if not handle:
raise SystemExit(dim("No name, no reading."))
out = args.out or f"tarot-{handle}"
cache = os.path.join(out, "github.json")
if args.offline:
with open(cache) as f:
profile = json.load(f)
flicker(candle(f"🔮 Reading @{handle} from the stored record …"))
else:
flicker(candle(f"🔮 Reaching through the veil for @{handle} …"))
profile = summon_profile(handle)
os.makedirs(out, exist_ok=True)
with open(cache, "w") as f:
json.dump(profile, f)
lay_out(args, handle, profile, deal(profile), out)
def lay_out(args: argparse.Namespace, handle: str, profile: dict, cards: list[Card], out: str) -> None:
os.makedirs(os.path.join(out, "cards"), exist_ok=True)
for c_ in cards:
art = _art_uri(c_) if args.style == "marseille" else None
with open(os.path.join(out, "cards", f"{c_.number:02d}-{c_.name.lower().replace(' ', '-')}.svg"), "w") as f:
f.write(card_svg_marseille(c_, handle, art) if art else card_svg(c_, handle))
flicker(dim(f" 22 cards dealt into {out}/cards\n"))
for c_ in cards:
print(f" {ROMAN[c_.number]:>5} {c_.name:<18} {dim(c_.fact)}")
spread_ = spread(cards)
print()
for pos, c_ in spread_:
flicker(candle(f" {pos:<8}") + f"{c_.name} · {c_.subject}")
reading = None
if not args.no_model:
facts = "\n".join(f"{pos}: {c_.name}. Subject: {c_.subject}. Fact: {c_.fact}" for pos, c_ in spread_)
who = profile["user"].get("name") or handle
print(ghost("\n👁 "), end="")
try:
reading = medium([{"role": "system", "content": TAROT_RULES},
{"role": "user", "content": f"Querent: {who} (@{handle}).\n{facts}"}])
except SystemExit as e:
print(e)
with open(os.path.join(out, "index.html"), "w") as f:
f.write(deck_html(handle, profile["user"], cards, spread_, reading))
with open(os.path.join(out, "reading.md"), "w") as f:
f.write(f"# The Tarot of @{handle}\n\n" + "\n".join(f"- **{p}: {c_.name}** · {c_.fact}" for p, c_ in spread_)
+ "\n\n" + (reading or "_(no reading)_") + "\n")
flicker(candle(f"\n🕯 The deck is laid out. Open {out}/index.html"))
def main(argv: list[str] | None = None) -> None:
p = argparse.ArgumentParser(prog="seance", description="Summon the ghosts of deleted code.")
sub = p.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("graveyard", help="list deleted files")
g.add_argument("-n", "--limit", type=int, default=20)
g.set_defaults(fn=cmd_graveyard)
s = sub.add_parser("summon", help="speak with the ghost of a deleted file")
s.add_argument("path")
s.add_argument("-a", "--ask", help="ask one question and exit")
s.add_argument("--dossier", action="store_true", help="print the evidence the ghost is given, then exit")
s.set_defaults(fn=cmd_summon)
o = sub.add_parser("ofrenda", help="honor contributors who have gone quiet")
o.add_argument("--quiet-days", type=int, default=365)
o.add_argument("-o", "--out", default="OFRENDA.md")
o.add_argument("--no-model", action="store_true", help="build the altar without the model")
o.set_defaults(fn=cmd_ofrenda)
t = sub.add_parser("tarot", help="deal a GitHub user's Major Arcana and read their spread")
t.add_argument("handle", nargs="?", help="GitHub handle (asked for if omitted)")
t.add_argument("--repo", action="store_true", help="read this git repository's history instead (fully offline)")
t.add_argument("-o", "--out", help="output directory (default: tarot-<handle>)")
t.add_argument("--offline", action="store_true", help="re-read the stored github.json instead of fetching")
t.add_argument("--no-model", action="store_true", help="deal the cards without a reading")
t.add_argument("--style", choices=["marseille", "gilded"], default="marseille",
help="marseille: illustrated cat deck (needs art/); gilded: pure vector line-art")
t.set_defaults(fn=cmd_tarot)
args = p.parse_args(argv)
args.fn(args)
if __name__ == "__main__":
main()
What's Next
-
seance haunt: a git hook where a file's ghost leaves one last comment on the PR that deletes it - Summoning a deleted directory as a family of ghosts (jQuery's
src/sizzleis waiting) -
seance ofrenda --html: a printable altar for the team retro on November 2 - Minor Arcana: 56 more cats
Go visit your graveyard. Someone you've forgotten wrote something that kept the lights on for years. 🕯️🌼
來源:DEV Community · dev.to


