commit fe3b39efdc5c37be4b8f57dd8792ad7db9c21dd2
parent 25fcf243b99a5720431ac515e22f6b91ab7eb842
Author: David Eisinger <[email protected]>
Date: Wed, 9 Sep 2026 00:02:43 -0400
Add pre-push hook to check dates
Diffstat:
5 files changed, 249 insertions(+), 1 deletion(-)
diff --git a/.githooks/pre-push b/.githooks/pre-push
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+exec "$(git rev-parse --show-toplevel)/bin/check-dispatch-dates" "$@"
diff --git a/README.md b/README.md
@@ -2,6 +2,37 @@
[1]: https://davideisinger.com
+## Publishing Dispatches
+
+Enable the repository's Git hooks once per clone:
+
+```sh
+git config core.hooksPath .githooks
+```
+
+Pushing to `origin`'s `main` checks newly added, non-draft Dispatches and existing
+Dispatches changing from draft to published. Their `date` must be no more than
+24 hours old. Edits or renames of already-published posts, draft posts, other
+branches, and other remotes are unaffected. The check reads the committed content
+being pushed and compares it with the remote's current commit.
+
+Before publishing, update the date and commit it:
+
+```sh
+bin/timestamp content/journal/dispatch-44-october-2026/index.md
+git add content/journal/dispatch-44-october-2026/index.md
+git commit -m "Set publication date"
+git push origin main
+```
+
+Without a filename, `bin/timestamp` still prints the timestamp and copies it to
+the clipboard. For intentional backdating, `git push --no-verify origin main`
+bypasses local push hooks for that push. The hook must be installed in each clone;
+it does not enforce dates on server-side merges or pushes from other clones.
+
+Run `bin/check-dispatch-hook` to exercise the workflow with temporary local Git
+repositories, without contacting the publishing remote.
+
---
© [CC BY 4.0 License][2]
diff --git a/bin/check-dispatch-dates b/bin/check-dispatch-dates
@@ -0,0 +1,82 @@
+#!/usr/bin/env ruby
+
+require "date"
+require "open3"
+require "shellwords"
+require "yaml"
+
+def git(*args)
+ output, error, status = Open3.capture3("git", *args)
+ raise "git #{args.first} failed: #{error.strip}" unless status.success?
+ output
+end
+
+def frontmatter(revision, path)
+ content = git("show", "#{revision}:#{path}")
+ match = content.match(/\A---\r?\n(.*?)\r?\n---(?:\r?\n|\z)/m)
+ raise "#{path}: missing YAML frontmatter" unless match
+ data = YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: false)
+ raise "#{path}: frontmatter must be a mapping" unless data.is_a?(Hash)
+ data
+end
+
+def publication_date(value)
+ return value.to_datetime if value.respond_to?(:to_datetime)
+ DateTime.iso8601(value.to_s)
+end
+
+begin
+ # Only the publishing remote's main branch needs this guard.
+ exit 0 unless ARGV[0] == "origin"
+ now = DateTime.now
+ problems = []
+ STDIN.each_line do |line|
+ local_ref, local_sha, remote_ref, remote_sha = line.split
+ raise "Invalid pre-push input" unless [local_ref, local_sha, remote_ref, remote_sha].all?
+ next unless remote_ref == "refs/heads/main"
+ next if local_sha.match?(/\A0+\z/) # Branch deletion.
+ if remote_sha.match?(/\A0+\z/)
+ raise "origin/main does not exist yet; cannot distinguish new posts from an existing archive."
+ end
+ unless system("git", "cat-file", "-e", "#{remote_sha}^{commit}", out: File::NULL, err: File::NULL)
+ raise "The current origin/main commit is unavailable locally. Run `git fetch origin` and retry."
+ end
+
+ changes = git("diff", "--name-status", "-z", "--find-renames", "--diff-filter=AMR",
+ remote_sha, local_sha, "--", "content/journal/").split("\0")
+ until changes.empty?
+ status = changes.shift
+ old_path = changes.shift
+ path = status.start_with?("R") ? changes.shift : old_path
+ next unless path.match?(%r{\Acontent/journal/dispatch-[^/]+/index\.md\z})
+
+ current = frontmatter(local_sha, path)
+ next if current["draft"] == true
+ unless status == "A"
+ previous = frontmatter(remote_sha, old_path)
+ next unless previous["draft"] == true
+ end
+
+ begin
+ date = publication_date(current["date"])
+ age_hours = (now - date) * 24
+ next if age_hours <= 24
+ reason = "publication date is #{age_hours.floor} hours old (maximum: 24 hours)"
+ rescue ArgumentError, TypeError
+ reason = "publication date is missing or invalid"
+ end
+ problems << "#{path}: #{reason}\n Update it: bin/timestamp #{Shellwords.escape(path)}"
+ end
+ end
+
+ unless problems.empty?
+ warn "Push blocked: newly published Dispatches need a current timestamp.\n\n"
+ warn problems.join("\n\n")
+ warn "\nCommit the updated date, then push again. The check reads the commit being pushed, not uncommitted edits."
+ warn "For intentional backdating, bypass once with `git push --no-verify origin main`."
+ exit 1
+ end
+rescue StandardError => error
+ warn "Dispatch publication check failed: #{error.message}"
+ exit 1
+end
diff --git a/bin/check-dispatch-hook b/bin/check-dispatch-hook
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Test the publication guard using disposable repositories and local pushes."""
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import tempfile
+
+
+repo = Path(__file__).resolve().parent.parent
+env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
+
+with tempfile.TemporaryDirectory(prefix="dispatch-hook-") as directory:
+ root = Path(directory)
+ work = root / "work"
+ work.mkdir()
+
+ def git(*args):
+ result = subprocess.run(["git", "-C", str(work), *args], env=env,
+ text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
+ return result.stdout.strip()
+
+ def post(name, hours=192, draft=False, date=True):
+ path = work / f"content/journal/dispatch-{name}/index.md"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ timestamp = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
+ field = f"date: {timestamp}\n" if date else ""
+ path.write_text(f"---\ntitle: Test\n{field}draft: {str(draft).lower()}\n"
+ "references:\n- date: 2020-01-01\n---\n\nBody.\ndate: Leave this alone.\n")
+ return path
+
+ def commit():
+ git("add", ".")
+ git("commit", "-m", "Test change")
+
+ def push(allowed, remote="origin", ref="main", message=None):
+ before = git("--git-dir", str(root / "origin.git"), "rev-parse", "main")
+ result = subprocess.run(["git", "-C", str(work), "push", remote, ref],
+ env=env, text=True, capture_output=True)
+ assert (result.returncode == 0) == allowed, result.stderr
+ if message:
+ assert message in result.stderr, result.stderr
+ if not allowed:
+ assert git("--git-dir", str(root / "origin.git"), "rev-parse", "main") == before
+
+ git("init", "-b", "main")
+ git("config", "user.name", "Hook Test")
+ git("config", "user.email", "[email protected]")
+ git("config", "commit.gpgsign", "false")
+ for remote in ("origin", "mirror"):
+ git("init", "--bare", str(root / f"{remote}.git"))
+ git("remote", "add", remote, str(root / f"{remote}.git"))
+ for file in (".githooks/pre-push", "bin/check-dispatch-dates", "bin/timestamp"):
+ target = work / file
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(repo / file, target)
+ published = post("published")
+ post("draft", draft=True)
+ commit()
+ git("push", "origin", "main")
+ git("config", "core.hooksPath", ".githooks")
+
+ # Old posts remain editable and renameable without changing their dates.
+ published.write_text(published.read_text() + "Correction.\n")
+ commit()
+ push(True)
+ git("mv", "content/journal/dispatch-published", "content/journal/dispatch-renamed")
+ commit()
+ push(True)
+
+ # Drafts may be old; publishing them must use a recent committed timestamp.
+ post("new-draft", draft=True)
+ commit()
+ push(True)
+ draft = post("draft", draft=False)
+ commit()
+ push(False, message="publication date is")
+ original = draft.read_text()
+ result = subprocess.run([str(work / "bin/timestamp"), str(draft)],
+ env=env, text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
+ assert draft.read_text() != original
+ assert "- date: 2020-01-01" in draft.read_text()
+ assert "date: Leave this alone." in draft.read_text()
+ push(False, message="publication date is") # Working-tree changes don't count.
+ commit()
+ push(True)
+
+ # Accept under 24 hours; block over 24 hours and missing dates.
+ post("recent", hours=23)
+ commit()
+ push(True)
+ post("stale", hours=25)
+ commit()
+ push(False, message="maximum: 24 hours")
+ push(True, ref="main:refs/heads/writing")
+ push(True, remote="mirror")
+ git("reset", "--hard", "origin/main")
+ post("missing-date", date=False)
+ commit()
+ push(False, message="publication date is missing or invalid")
+ git("reset", "--hard", "origin/main")
+
+ # A differently named local branch still gets checked when publishing main.
+ git("checkout", "-b", "publishing")
+ post("from-branch")
+ commit()
+ push(False, ref="publishing:main", message="publication date is")
+
+ # No known remote baseline must fail clearly, rather than checking the archive.
+ sha = git("rev-parse", "HEAD")
+ result = subprocess.run([str(work / "bin/check-dispatch-dates"), "origin"],
+ cwd=work, env=env, text=True, capture_output=True,
+ input=f"refs/heads/main {sha} refs/heads/main {'0' * 40}\n")
+ assert result.returncode != 0 and "does not exist yet" in result.stderr
+
+print("Dispatch hook checks passed: real local pushes, draft publication, timestamps, renames, and remote/branch scope.")
diff --git a/bin/timestamp b/bin/timestamp
@@ -2,8 +2,19 @@
require "date"
+abort "Usage: bin/timestamp [path/to/index.md]" if ARGV.length > 1
ts = DateTime.now.iso8601
-IO.popen("pbcopy", "w") { |pb| pb.write(ts) }
+if ARGV.empty?
+ IO.popen("pbcopy", "w") { |pb| pb.write(ts) }
+else
+ path = ARGV.first
+ content = File.read(path)
+ frontmatter = content.match(/\A---\r?\n(.*?)\r?\n---(?:\r?\n|\z)/m)
+ abort "#{path}: expected YAML frontmatter with one date field" unless frontmatter && frontmatter[1].scan(/^date:[^\r\n]*/).length == 1
+ updated = frontmatter[0].sub(/^date:[^\r\n]*/, "date: #{ts}")
+ File.write(path, updated + content[frontmatter[0].length..-1])
+ puts "Updated #{path}"
+end
puts ts