davideisinger.com

My personal website
Log | Files | Refs | README

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:
A.githooks/pre-push | 4++++
MREADME.md | 31+++++++++++++++++++++++++++++++
Abin/check-dispatch-dates | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abin/check-dispatch-hook | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbin/timestamp | 13++++++++++++-
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