Turn the commit history of a hand-picked list of GitHub repos into Atom feeds
where every entry contains the complete inline unified diff of the commit —
the actual diff --git … @@ … +/- hunks, HTML-escaped in a <pre> block — not just
the message and a link like GitHub's native commits.atom.
It runs once and exits. Point it at a config, schedule it (cron / systemd timer),
and let FreshRSS poll the static .xml files (served by nginx). No server, no database,
no git clone — pure HTTP against the GitHub REST API.
- Node.js 20+ (uses the built-in global
fetch; zero runtime dependencies). - A GitHub token (optional for public repos but strongly recommended — it raises the rate limit from 60 to 5000 requests/hour and is required for private repos).
npm install # installs tsx (runner) + typescript (typecheck) as dev depsCopy the example and edit it:
cp config.example.json config.json{
"outputDir": "./feeds",
"cacheDir": "./cache",
"maxCommits": 25,
"maxDiffBytes": 200000,
"repos": [
{ "owner": "owner1", "repo": "repo1", "branch": "main", "title": "Friendly name" },
{ "owner": "owner2", "repo": "private-repo" }
]
}outputDir— where the.xmlfeeds are written (one file per repo,{owner}-{repo}.xml).cacheDir— per-SHA diff cache; each commit's diff is downloaded exactly once, ever.maxCommits— most recent N commits per repo (1 page, max 100).maxDiffBytes— diffs larger than this are truncated with a "see commit" notice. The full diff is still cached, so raising this limit later does not re-download anything.repos[].branch— optional; omit to use the repo's default branch.repos[].title— optional friendly feed title; defaults toowner/repo.
The token comes from the GITHUB_TOKEN environment variable — never from the config
file, never hardcoded. A fine-grained token with read-only "Contents" access is enough.
export GITHUB_TOKEN=ghp_xxx
npm start # uses ./config.json
# or point at another config:
npx tsx src/index.ts /etc/git-diff-rss/config.jsonType-check and self-test:
npm run typecheck
npm testNode 23.6+ can run the TypeScript directly with no dev deps:
node src/index.ts config.json.
/etc/systemd/system/git-diff-rss.service (oneshot — runs and exits):
[Unit]
Description=Generate GitHub commit-diff Atom feeds
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/git-diff-rss
Environment=GITHUB_TOKEN=ghp_xxx
ExecStart=/usr/bin/npx tsx src/index.ts /opt/git-diff-rss/config.json/etc/systemd/system/git-diff-rss.timer (every 30 minutes):
[Unit]
Description=Run git-diff-rss every 30 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=30min
Persistent=true
[Install]
WantedBy=timers.targetEnable it:
sudo systemctl daemon-reload
sudo systemctl enable --now git-diff-rss.timer
systemctl list-timers git-diff-rss.timer # check next run
journalctl -u git-diff-rss.service # check logsKeep the token out of the unit file by using
EnvironmentFile=/etc/git-diff-rss.env(chmod 600) with a singleGITHUB_TOKEN=ghp_xxxline instead ofEnvironment=.
Point a location at your outputDir:
location /feeds/ {
alias /opt/git-diff-rss/feeds/;
types { application/atom+xml xml; }
default_type application/atom+xml;
autoindex on; # optional: browse the list of feeds
}Now https://your-host/feeds/owner1-repo1.xml serves the Atom file.
- In FreshRSS: Subscription management → Add → a feed.
- Paste the feed URL, e.g.
https://your-host/feeds/owner1-repo1.xml. - FreshRSS auto-detects Atom and starts polling. Each article body shows the commit
subject/message followed by the full diff in a
<pre>block.
To keep long diffs readable, FreshRSS's default article view already renders the <pre>
monospaced; no extra configuration is needed.
GET /repos/{owner}/{repo}/commits?sha={branch}&per_page={maxCommits}→ commit metadata.- For each commit, the diff comes from
cacheDir/{sha}.diffif present, otherwiseGET /repos/{owner}/{repo}/commits/{sha}withAccept: application/vnd.github.diff, then it's written to the cache. - Each entry's diff is HTML-escaped, wrapped in
<pre><code>, and the whole HTML body is escaped again for Atomcontent type="html". - One
.xmlfile is written per repo.
Resilience: a repo that errors (network / 404 / rate limit) is logged and skipped; the
others still generate. When GitHub reports X-RateLimit-Remaining: 0, the run stops fetching
new diffs and finishes from cache — uncached commits are picked up on the next run.