git2tweet 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.3
2
+ Name: git2tweet
3
+ Version: 0.1.0
4
+ Summary: Turn your git commits into tweet drafts with an AI agent and human approval
5
+ Author: Vedant
6
+ Author-email: Vedant <vkowdiki@gmail.com>
7
+ Requires-Dist: langchain-core>=1.7.6
8
+ Requires-Dist: langchain-groq>=1.1.3
9
+ Requires-Dist: langgraph>=1.2.20
10
+ Requires-Dist: platformdirs>=4.12.4
11
+ Requires-Dist: python-dotenv>=1.2.4
12
+ Requires-Dist: langchain-ollama>=1.1.0 ; extra == 'ollama'
13
+ Requires-Python: >=3.10
14
+ Provides-Extra: ollama
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Git2Tweet
18
+
19
+ Turn your git commits into tweet drafts. You approve every one before it goes out.
20
+
21
+ ![demo](https://raw.githubusercontent.com/Vex-15/git2tweet/main/docs/demo.gif)
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pipx install git2tweet # or: uv tool install git2tweet
27
+ git2tweet config # pick a model provider, paste your API key (once)
28
+ ```
29
+
30
+ ## Use it
31
+
32
+ ```bash
33
+ git commit -m "feat: add retry with backoff to the webhook handler"
34
+ git tweet # drafts a tweet, you approve or edit, X opens prefilled
35
+ ```
36
+
37
+ Want a reminder after every commit? Run `git2tweet init` once per repo.
38
+
39
+ | Command | What it does |
40
+ |---|---|
41
+ | `git tweet` / `g2t` | Draft a tweet for your latest commit |
42
+ | `git tweet <sha>` | Draft for a specific commit |
43
+ | `git tweet --force` | Draft again even if the commit was already handled |
44
+ | `git2tweet config` | Set provider, model and API key |
45
+ | `git2tweet init` | Install the post-commit reminder hook (`--remove` to undo) |
46
+
47
+ ## How it works
48
+
49
+ An agent (built with LangGraph) reads your commit and diff, decides if it's worth
50
+ sharing, and drafts a tweet. Trivial commits (typos, formatting) are skipped.
51
+ You then approve, edit, or reject it. Approved tweets open in X with the text
52
+ prefilled, so you press Post yourself. Git2Tweet never holds your X credentials.
53
+
54
+ ## Privacy
55
+
56
+ - The commit message and diff are sent to the model provider you choose (Groq by default).
57
+ - Known secret patterns (API keys, tokens, `password=...`) are redacted first.
58
+ This is best effort, so don't rely on it for sensitive repos.
59
+ - For fully local use, pick `ollama` in `git2tweet config`: nothing leaves your machine.
60
+ - Git2Tweet sends no telemetry.
61
+
62
+ ## License
63
+
64
+ MIT
65
+
66
+ [View full license](https://github.com/Vex-15/git2tweet?tab=MIT-1-ov-file)
@@ -0,0 +1,50 @@
1
+ # Git2Tweet
2
+
3
+ Turn your git commits into tweet drafts. You approve every one before it goes out.
4
+
5
+ ![demo](https://raw.githubusercontent.com/Vex-15/git2tweet/main/docs/demo.gif)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pipx install git2tweet # or: uv tool install git2tweet
11
+ git2tweet config # pick a model provider, paste your API key (once)
12
+ ```
13
+
14
+ ## Use it
15
+
16
+ ```bash
17
+ git commit -m "feat: add retry with backoff to the webhook handler"
18
+ git tweet # drafts a tweet, you approve or edit, X opens prefilled
19
+ ```
20
+
21
+ Want a reminder after every commit? Run `git2tweet init` once per repo.
22
+
23
+ | Command | What it does |
24
+ |---|---|
25
+ | `git tweet` / `g2t` | Draft a tweet for your latest commit |
26
+ | `git tweet <sha>` | Draft for a specific commit |
27
+ | `git tweet --force` | Draft again even if the commit was already handled |
28
+ | `git2tweet config` | Set provider, model and API key |
29
+ | `git2tweet init` | Install the post-commit reminder hook (`--remove` to undo) |
30
+
31
+ ## How it works
32
+
33
+ An agent (built with LangGraph) reads your commit and diff, decides if it's worth
34
+ sharing, and drafts a tweet. Trivial commits (typos, formatting) are skipped.
35
+ You then approve, edit, or reject it. Approved tweets open in X with the text
36
+ prefilled, so you press Post yourself. Git2Tweet never holds your X credentials.
37
+
38
+ ## Privacy
39
+
40
+ - The commit message and diff are sent to the model provider you choose (Groq by default).
41
+ - Known secret patterns (API keys, tokens, `password=...`) are redacted first.
42
+ This is best effort, so don't rely on it for sensitive repos.
43
+ - For fully local use, pick `ollama` in `git2tweet config`: nothing leaves your machine.
44
+ - Git2Tweet sends no telemetry.
45
+
46
+ ## License
47
+
48
+ MIT
49
+
50
+ [View full license](https://github.com/Vex-15/git2tweet?tab=MIT-1-ov-file)
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "git2tweet"
3
+ version = "0.1.0"
4
+ description = "Turn your git commits into tweet drafts with an AI agent and human approval"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "langchain-core>=1.7.6",
9
+ "langchain-groq>=1.1.3",
10
+ "langgraph>=1.2.20",
11
+ "platformdirs>=4.12.4",
12
+ "python-dotenv>=1.2.4",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Vedant"
17
+ email = "vkowdiki@gmail.com"
18
+
19
+ [project.scripts]
20
+ git2tweet = "git2tweet.cli:main"
21
+ g2t = "git2tweet.cli:main"
22
+ git-tweet = "git2tweet.cli:main"
23
+
24
+ [project.optional-dependencies]
25
+ ollama = ["langchain-ollama>=1.1.0"]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.13.5,<0.14.0"]
29
+ build-backend = "uv_build"
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "git2tweet"
3
+ version = "0.1.0"
4
+ description = "Turn your git commits into tweet drafts with an AI agent and human approval"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Vedant", email = "vkowdiki@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "langchain-core>=1.7.6",
12
+ "langchain-groq>=1.1.3",
13
+ "langgraph>=1.2.20",
14
+ "platformdirs>=4.12.4",
15
+ "python-dotenv>=1.2.4",
16
+ ]
17
+
18
+ [project.scripts]
19
+ git2tweet = "git2tweet.cli:main"
20
+ g2t = "git2tweet.cli:main"
21
+ git-tweet = "git2tweet.cli:main"
22
+
23
+ [project.optional-dependencies]
24
+ ollama = [
25
+ "langchain-ollama>=1.1.0",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.13.5,<0.14.0"]
30
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,119 @@
1
+ import json
2
+ import webbrowser
3
+ from typing import Annotated, TypedDict
4
+ from urllib.parse import urlencode
5
+
6
+ from langchain_core.messages import HumanMessage, SystemMessage
7
+ from langchain_core.tools import tool
8
+ from langgraph.checkpoint.memory import MemorySaver
9
+ from langgraph.graph import END, START, StateGraph
10
+ from langgraph.graph.message import add_messages
11
+ from langgraph.prebuilt import ToolNode, tools_condition
12
+ from langgraph.types import Command, interrupt
13
+
14
+ from . import repo, store
15
+ from .llm import make_llm
16
+
17
+ SYSTEM = """You write tweets for a developer who builds in public.
18
+ Look at the recent commits, pick the most tweet-worthy one, read its diff,
19
+ then write ONE tweet under 280 characters (first person, casual, max 1 emoji,
20
+ max 2 hashtags, never include secrets).
21
+ If every commit is trivial (typos, formatting, merges), reply with exactly: SKIP
22
+ Output only the tweet text or SKIP."""
23
+
24
+
25
+ @tool
26
+ def list_recent_commits(n: int = 3) -> str:
27
+ """List the most recent commits (sha + first line of message)."""
28
+ return json.dumps(repo.list_commits(n))
29
+
30
+
31
+ @tool
32
+ def get_commit_diff(sha: str) -> str:
33
+ """Get the full message and code diff for one commit by its sha."""
34
+ return f"Message: {repo.commit_message(sha)}\n\n{repo.commit_diff(sha)}"
35
+
36
+
37
+ TOOLS = [list_recent_commits, get_commit_diff]
38
+
39
+
40
+ class State(TypedDict):
41
+ messages: Annotated[list, add_messages]
42
+ tweet: str
43
+ status: str
44
+
45
+
46
+ def approve(state: State):
47
+ draft = state["messages"][-1].content.strip()
48
+ if draft.strip(" .\"'").upper() == "SKIP":
49
+ return {"status": "skipped"}
50
+
51
+ answer = interrupt({"draft": draft, "chars": len(draft)}).strip()
52
+
53
+ if answer.lower() == "y":
54
+ return {"tweet": draft, "status": "approved"}
55
+ if answer.lower() == "n":
56
+ return {"status": "rejected"}
57
+ return {"tweet": answer, "status": "approved"} # anything else = your edit
58
+
59
+
60
+ def post(state: State):
61
+ url = "https://twitter.com/intent/tweet?" + urlencode({"text": state["tweet"]})
62
+ webbrowser.open(url)
63
+ return {"status": "opened"}
64
+
65
+
66
+ def after_approve(state: State):
67
+ return "post" if state["status"] == "approved" else END
68
+
69
+
70
+ def build_graph():
71
+ llm = make_llm().bind_tools(TOOLS)
72
+
73
+ def agent(state: State):
74
+ reply = llm.invoke([SystemMessage(SYSTEM)] + state["messages"])
75
+ return {"messages": [reply]}
76
+
77
+ builder = StateGraph(State)
78
+ builder.add_node("agent", agent)
79
+ builder.add_node("tools", ToolNode(TOOLS))
80
+ builder.add_node("approve", approve)
81
+ builder.add_node("post", post)
82
+
83
+ builder.add_edge(START, "agent")
84
+ builder.add_conditional_edges(
85
+ "agent", tools_condition, {"tools": "tools", END: "approve"}
86
+ )
87
+ builder.add_edge("tools", "agent")
88
+ builder.add_conditional_edges(
89
+ "approve", after_approve, {"post": "post", END: END}
90
+ )
91
+ builder.add_edge("post", END)
92
+ return builder.compile(checkpointer=MemorySaver())
93
+
94
+
95
+ def run(sha: str) -> None:
96
+ if sha in store.load():
97
+ print(f"Already handled {sha[:7]}, skipping.")
98
+ return
99
+
100
+ graph = build_graph()
101
+ config = {"configurable": {"thread_id": sha}, "recursion_limit": 10}
102
+ task = f"Write a tweet about commit {sha}. Read its diff with get_commit_diff first."
103
+
104
+ result = graph.invoke({"messages": [HumanMessage(task)]}, config)
105
+
106
+ while "__interrupt__" in result:
107
+ info = result["__interrupt__"][0].value
108
+ print(f"\nDRAFT ({info['chars']} chars):\n{info['draft']}\n")
109
+ answer = input("Post? [y] yes / [n] no / or type your edited tweet: ")
110
+ result = graph.invoke(Command(resume=answer), config)
111
+
112
+ status = result["status"]
113
+ print("Status:", status)
114
+
115
+ if status == "opened":
116
+ if input("Did you press Post on X? [y/n] ").lower() == "y":
117
+ store.mark(sha)
118
+ elif status in ("skipped", "rejected"):
119
+ store.mark(sha)
@@ -0,0 +1,108 @@
1
+ import argparse
2
+ import getpass
3
+ import json
4
+ import subprocess
5
+ import sys
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ from . import config, hooks, repo, store
10
+
11
+ COMMANDS = {"draft", "config", "init", "hook"}
12
+
13
+
14
+ def configure(show: bool) -> None:
15
+ cfg = config.load()
16
+
17
+ if show:
18
+ shown = dict(cfg)
19
+ if "groq_api_key" in shown:
20
+ shown["groq_api_key"] = "****" + shown["groq_api_key"][-4:]
21
+ print(json.dumps(shown, indent=2))
22
+ print(f"\nConfig file: {config.CONFIG_FILE}")
23
+ return
24
+
25
+ current = cfg.get("provider", "groq")
26
+ provider = input(f"Provider [groq/ollama] ({current}): ").strip().lower() or current
27
+ if provider not in config.DEFAULT_MODELS:
28
+ sys.exit(f"Unknown provider '{provider}'. Choose: {', '.join(config.DEFAULT_MODELS)}")
29
+
30
+ default_model = (cfg.get("model") if provider == cfg.get("provider") else None) \
31
+ or config.DEFAULT_MODELS[provider]
32
+ cfg["provider"] = provider
33
+ cfg["model"] = input(f"Model ({default_model}): ").strip() or default_model
34
+
35
+ if provider == "groq":
36
+ key = getpass.getpass("Groq API key (hidden, Enter keeps current): ").strip()
37
+ if key:
38
+ cfg["groq_api_key"] = key
39
+
40
+ config.save(cfg)
41
+ print(f"Saved to {config.CONFIG_FILE}")
42
+
43
+
44
+ def draft(sha: str) -> None:
45
+ from .agent import run # imported here so --help, config and hook stay fast
46
+
47
+ try:
48
+ full_sha = repo.resolve(sha)
49
+ except (subprocess.CalledProcessError, FileNotFoundError):
50
+ sys.exit("Run this inside a git repository, with a valid commit.")
51
+ run(full_sha)
52
+
53
+
54
+ def init(remove: bool) -> None:
55
+ try:
56
+ msg = hooks.remove() if remove else hooks.install()
57
+ except (subprocess.CalledProcessError, FileNotFoundError):
58
+ sys.exit("Run this inside a git repository.")
59
+ print(msg)
60
+ if msg.startswith("Installed"):
61
+ print("After each commit you'll see a reminder. Run `git2tweet` to draft a tweet.")
62
+
63
+ def hook() -> None:
64
+ """Called by the post-commit hook: must be fast, quiet, and never fail."""
65
+ try:
66
+ sha = repo.resolve("HEAD")
67
+ if sha in store.load() or repo.is_merge(sha):
68
+ return
69
+ subject = repo.list_commits(1)[0]["message"]
70
+ print(f"git2tweet: tweet this? Run `git tweet` ({sha[:7]}: {subject})")
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ def main() -> None:
76
+ load_dotenv() # dev convenience: reads a .env in the current folder
77
+
78
+ parser = argparse.ArgumentParser(
79
+ prog="git2tweet",
80
+ description="Turn a git commit into a tweet draft you approve before posting.",
81
+ epilog="Run `git2tweet` alone to draft a tweet for your latest commit.",
82
+ )
83
+ sub = parser.add_subparsers(dest="command", metavar="{draft,config,init}")
84
+
85
+ p_draft = sub.add_parser("draft", help="draft a tweet for a commit (the default)")
86
+ p_draft.add_argument("sha", nargs="?", default="HEAD", help="commit to use (default: latest)")
87
+
88
+ p_config = sub.add_parser("config", help="set provider, model and API key")
89
+ p_config.add_argument("--show", action="store_true", help="show current settings")
90
+
91
+ p_init = sub.add_parser("init", help="install a git hook that reminds you after each commit")
92
+ p_init.add_argument("--remove", action="store_true", help="uninstall the hook")
93
+
94
+ sub.add_parser("hook", help=argparse.SUPPRESS) # internal, used by the hook itself
95
+
96
+ argv = sys.argv[1:]
97
+ if not argv or (argv[0] not in COMMANDS and not argv[0].startswith("-")):
98
+ argv = ["draft", *argv]
99
+ args = parser.parse_args(argv)
100
+
101
+ if args.command == "config":
102
+ configure(args.show)
103
+ elif args.command == "init":
104
+ init(args.remove)
105
+ elif args.command == "hook":
106
+ hook()
107
+ else:
108
+ draft(args.sha)
@@ -0,0 +1,46 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from platformdirs import user_config_dir
6
+
7
+ CONFIG_FILE = Path(user_config_dir("git2tweet")) / "config.json"
8
+
9
+ DEFAULT_MODELS = {
10
+ "groq": "openai/gpt-oss-120b",
11
+ "ollama": "llama3.1",
12
+ }
13
+
14
+
15
+ def load() -> dict:
16
+ if not CONFIG_FILE.exists():
17
+ return {}
18
+ try:
19
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
20
+ except json.JSONDecodeError:
21
+ return {}
22
+
23
+
24
+ def save(cfg: dict) -> None:
25
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
26
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
27
+ try:
28
+ CONFIG_FILE.chmod(0o600) # owner-only on Mac/Linux
29
+ except OSError:
30
+ pass
31
+
32
+
33
+ def get_settings() -> dict:
34
+ """Environment variables win over the config file, which wins over defaults."""
35
+ cfg = load()
36
+ provider = os.environ.get("GIT2TWEET_PROVIDER") or cfg.get("provider", "groq")
37
+ model = (
38
+ os.environ.get("GIT2TWEET_MODEL")
39
+ or cfg.get("model")
40
+ or DEFAULT_MODELS.get(provider, "")
41
+ )
42
+ return {"provider": provider, "model": model}
43
+
44
+
45
+ def get_groq_key() -> str | None:
46
+ return os.environ.get("GROQ_API_KEY") or load().get("groq_api_key")
@@ -0,0 +1,54 @@
1
+ import stat
2
+
3
+ from . import repo
4
+
5
+ MARKER_START = "# >>> git2tweet >>>"
6
+ MARKER_END = "# <<< git2tweet <<<"
7
+ BLOCK = (
8
+ f"{MARKER_START}\n"
9
+ "command -v git2tweet >/dev/null 2>&1 && git2tweet hook || true\n"
10
+ f"{MARKER_END}\n"
11
+ )
12
+
13
+
14
+ def _path():
15
+ return repo.hooks_dir() / "post-commit"
16
+
17
+
18
+ def install() -> str:
19
+ path = _path()
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+
22
+ if path.exists():
23
+ text = path.read_text(encoding="utf-8")
24
+ if MARKER_START in text:
25
+ return f"Already installed ({path})"
26
+ first = text.splitlines()[0] if text else ""
27
+ if not (first.startswith("#!") and first.rstrip().endswith("sh")):
28
+ return "A non-shell post-commit hook already exists. Add `git2tweet hook` to it manually."
29
+ text = text.rstrip("\n") + "\n\n" + BLOCK
30
+ else:
31
+ text = "#!/bin/sh\n\n" + BLOCK
32
+
33
+ path.write_text(text, encoding="utf-8", newline="\n") # LF only, or sh breaks on Windows
34
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
35
+ return f"Installed post-commit hook ({path})"
36
+
37
+
38
+ def remove() -> str:
39
+ path = _path()
40
+ if not path.exists():
41
+ return "No git2tweet hook found."
42
+ text = path.read_text(encoding="utf-8")
43
+ if MARKER_START not in text:
44
+ return "No git2tweet hook found."
45
+
46
+ start = text.index(MARKER_START)
47
+ end = text.index(MARKER_END) + len(MARKER_END)
48
+ rest = (text[:start] + text[end:]).strip()
49
+
50
+ if rest in ("", "#!/bin/sh"):
51
+ path.unlink() # the hook was only ours, so delete the file
52
+ else:
53
+ path.write_text(rest + "\n", encoding="utf-8", newline="\n")
54
+ return "Removed git2tweet hook."
@@ -0,0 +1,22 @@
1
+ from . import config
2
+
3
+
4
+ def make_llm():
5
+ s = config.get_settings()
6
+
7
+ if s["provider"] == "groq":
8
+ from langchain_groq import ChatGroq
9
+
10
+ key = config.get_groq_key()
11
+ if not key:
12
+ raise SystemExit("No Groq API key found. Run `git2tweet config` or set GROQ_API_KEY.")
13
+ return ChatGroq(model=s["model"], api_key=key)
14
+
15
+ if s["provider"] == "ollama":
16
+ try:
17
+ from langchain_ollama import ChatOllama
18
+ except ImportError:
19
+ raise SystemExit('Ollama support is not installed. Run: pip install "git2tweet[ollama]"')
20
+ return ChatOllama(model=s["model"])
21
+
22
+ raise SystemExit(f"Unknown provider '{s['provider']}'. Use groq or ollama.")
@@ -0,0 +1,48 @@
1
+ import subprocess
2
+
3
+ from .scrub import scrub
4
+
5
+
6
+ def _git(*args: str) -> str:
7
+ result = subprocess.run(
8
+ ["git", *args],
9
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
10
+ check=True,
11
+ )
12
+ return result.stdout.strip()
13
+
14
+
15
+ def resolve(ref: str = "HEAD") -> str:
16
+ """Turn HEAD, a short sha, or a branch name into a full sha."""
17
+ return _git("rev-parse", ref)
18
+
19
+
20
+ def list_commits(n: int = 3) -> list[dict]:
21
+ out = _git("log", f"-{n}", "--pretty=format:%H%x09%s")
22
+ rows = [line.split("\t", 1) for line in out.splitlines()]
23
+ return [{"sha": sha, "message": scrub(msg)} for sha, msg in rows]
24
+
25
+
26
+ def commit_message(sha: str) -> str:
27
+ return scrub(_git("show", "-s", "--format=%B", sha))
28
+
29
+
30
+ def commit_diff(sha: str, max_chars: int = 6000) -> str:
31
+ return scrub(_git("show", "--format=", "--patch", sha))[:max_chars]
32
+
33
+
34
+ from pathlib import Path
35
+
36
+ def git_dir() -> Path:
37
+ """The repo's .git folder (works from any subfolder, worktrees too)."""
38
+ return Path(_git("rev-parse", "--absolute-git-dir"))
39
+
40
+
41
+ def hooks_dir() -> Path:
42
+ """Where this repo's hooks live (respects a custom core.hooksPath)."""
43
+ return Path(_git("rev-parse", "--git-path", "hooks")).resolve()
44
+
45
+
46
+ def is_merge(sha: str) -> bool:
47
+ parts = _git("rev-list", "--parents", "-n", "1", sha).split()
48
+ return len(parts) > 2 # sha + 2 or more parents
@@ -0,0 +1,16 @@
1
+ import re
2
+
3
+ PATTERNS = [
4
+ r"ghp_[A-Za-z0-9]{36}",
5
+ r"github_pat_[A-Za-z0-9_]{20,}",
6
+ r"gsk_[A-Za-z0-9]{20,}",
7
+ r"sk-[A-Za-z0-9_-]{20,}",
8
+ r"AKIA[0-9A-Z]{16}",
9
+ r"(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+",
10
+ ]
11
+
12
+
13
+ def scrub(text: str) -> str:
14
+ for p in PATTERNS:
15
+ text = re.sub(p, "[REDACTED]", text)
16
+ return text
@@ -0,0 +1,23 @@
1
+ import json
2
+
3
+ from . import repo
4
+
5
+
6
+ def _file():
7
+ return repo.git_dir() / "git2tweet.json"
8
+
9
+
10
+ def load() -> set[str]:
11
+ f = _file()
12
+ if not f.exists():
13
+ return set()
14
+ try:
15
+ return set(json.loads(f.read_text(encoding="utf-8")))
16
+ except (json.JSONDecodeError, TypeError):
17
+ return set()
18
+
19
+
20
+ def mark(sha: str) -> None:
21
+ shas = load()
22
+ shas.add(sha)
23
+ _file().write_text(json.dumps(sorted(shas)), encoding="utf-8")