db: the dump commits what it wrote
The git the console ran before its prompt moves into db:dump. The
console was the one caller that could assume git in the image; now
that the image carries it outright, every dump can be a snapshot
there is a way back to rather than an overwrite of the last one.
A dump that moved nothing commits nothing, so the history is the
writes and not the runs, and the identity is passed per invocation
so a host that dumps needs no git identity set up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AAVM6wKqDQV421LcZfKdx
diff --git a/AGENTS.md b/AGENTS.md
index 5843f81..7b7db2f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,12 +22,13 @@ rake contacts:reid # Move contacts whose ids are not server-minted
rake book:name # Name a login's book and rename its sync group
# (LOGIN=login NAME=name; no NAME clears it)
rake db:dump # Write the cards, birthdays, and groups to data/dump
+ # and commit them to the repository there
# (DUMP=path to move it)
rake dev # Dev server, seeded from test/fixtures/cards into a
# throwaway tmpdir on every start, reloading on change
# (needs fd and entr)
rake console # irb with the store open on the configured database
- # (console.rb loads it), after committing the dump as a
+ # (console.rb loads it), after db:dump commits a
# safety net for what the session might do there
rake import:macos:plan # Plan importing this Mac's iCloud contacts
# (LIMIT=n; reads Contacts, so leave it to the user)
diff --git a/Dockerfile b/Dockerfile
index c70d612..1cfd158 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,9 +22,9 @@ ENV VERSION=${VERSION}
ENV RACK_ENV=production
# Set TZ at runtime — Date.today drives the birthday list's rollover.
-# git, for the dump `rake console` commits before its prompt
-# (tasks/console.rake): a mistake at the prompt is then a revert in
-# the dump's repository rather than a restore from backup.
+# git, for the commit `rake db:dump` makes of what it wrote
+# (tasks/db.rake): a snapshot is then something to revert to rather
+# than the last one overwritten.
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
diff --git a/README.md b/README.md
index de5f0fd..e27bdcd 100644
--- a/README.md
+++ b/README.md
@@ -39,8 +39,10 @@ birthdays because a partial date has no vCard 3.0 spelling — and an
index of parsed properties, which can: `rake index:rebuild` derives
that again from the stored cards alone. `rake db:dump` writes the
cards, birthdays, and groups out as plain files, into `data/dump`
-unless `DUMP` names another directory
-(`docs/plans/2026-09-12-database-dump.md`).
+unless `DUMP` names another directory, and commits them to that
+directory's own repository, so a snapshot is something to revert to
+(`docs/plans/2026-09-12-database-dump.md`,
+`docs/plans/2026-09-20-the-dump-commits.md`).
A group holds attributes its members' cards carry: an address or a note
written once and composed into every member's card on the way out, with
diff --git a/docs/plans/2026-09-20-the-dump-commits.md b/docs/plans/2026-09-20-the-dump-commits.md
new file mode 100644
index 0000000..e07f7e1
--- /dev/null
+++ b/docs/plans/2026-09-20-the-dump-commits.md
@@ -0,0 +1,49 @@
+# The dump commits
+
+2026-09-20. `rake db:dump` commits what it wrote.
+`2026-09-12-database-dump.md` left history as "whatever the
+directory's owner keeps of it", and `rake console` was that owner: it
+took the dump, then ran git over it, so a session that deleted the
+wrong contact was a revert rather than a restore from backup. The git
+moves into `tasks/db.rake` and the console keeps only the `db:dump`
+it already invoked.
+
+## Why it belonged to the dump
+
+The console was the one caller that could assume git — the image
+carried it for that task and nothing else. Now that git is in the
+image outright, the assumption is the dump's to make, and the dump is
+where it fits: the console commits because a prompt is dangerous, but
+a snapshot nothing can revert to is a worse dump whatever took it.
+Every other caller — a cron, a hand-run `rake db:dump` before an
+upgrade, `DUMP=path` to a scratch directory — got an overwrite of the
+last snapshot and no way back to it, which was never a considered
+decision, only where the code sat.
+
+So there is no flag to dump without committing. A dump the repository
+does not record is the thing this replaces, and `data/dump` is
+ignored by the code's repository, so the dump's own is the only place
+that history can live.
+
+## What a commit says
+
+`dump <ISO 8601 UTC>`, and nothing else. The time is all a snapshot
+has to say for itself: what moved is the change log, which the dump
+leaves out on purpose (`2026-09-12-database-dump.md`), and the diff
+of the commit says the rest. A dump that moved nothing commits
+nothing, so the history is the writes and not the runs.
+
+The repository is initialized on the first dump, on `main`. The
+identity is passed with `-c` per invocation rather than read from the
+machine's configuration, so a host that dumps needs no git identity
+set up and a deployment's commits do not borrow a person's name.
+
+## Still no git in the app
+
+This is the tooling, not the server. The app runs no git, issues no
+commit from a web request, and has no git on its write path —
+`2026-08-24-vcard-storage-and-groups.md` proposed that and
+`2026-09-12-database-dump.md` withdrew it. `tasks/db.rake` is the one
+place in the repository that shells out to git for the dump, and a
+failure there raises: a caller that was told it dumped was told it
+committed.
diff --git a/tasks/console.rake b/tasks/console.rake
index ce53cb6..da377cc 100644
--- a/tasks/console.rake
+++ b/tasks/console.rake
@@ -1,40 +1,18 @@
-require "open3"
-require "pathname"
-
# For the operations no screen has: deleting a contact outright,
# dissolving a group. The session loads console.rb at the root, which
# opens the store; exec rather than sh, so the console is the process
# the terminal talks to and Ctrl-D ends it rather than a rake around
# it.
#
-# Before the prompt, the dump is taken and committed, so a session
-# starts from a recorded snapshot of the three things the store cannot
-# rebuild (cards, birthdays, groups — the tables tasks/db.rake dumps)
-# and a mistake at the prompt is a revert in the dump's repository
-# rather than a restore from backup. This is the one place the tooling
-# runs git — the app still runs none
-# (docs/plans/2026-09-12-database-dump.md) — and the dump directory is
-# its own repository beside the code's, `data/` being ignored here.
+# Before the prompt, the dump is taken, and taking it is committing it
+# (tasks/db.rake), so a session starts from a recorded snapshot of the
+# three things the store cannot rebuild and a mistake at the prompt is
+# a revert in the dump's repository rather than a restore from backup.
+# db:dump raising is what keeps the console from opening on a session
+# its safety net never armed.
desc "Open an irb console on the configured database, the dump committed first"
task :console do
Rake::Task["db:dump"].invoke
- require "pro_tacts"
- dump = Pathname.new(ENV.fetch("DUMP") { (ProTacts.config.data_dir / "dump").to_s })
- sh "git", "-C", dump.to_s, "init", "-b", "main" unless (dump / ".git").exist?
- sh "git", "-C", dump.to_s, "add", "--all"
-
- # Porcelain is the whole answer: empty means nothing to commit. A
- # failure here must stop the console from opening rather than start
- # a session its safety net never armed.
- staged, error, = Open3.capture3("git", "-C", dump.to_s, "status", "--porcelain")
- raise error unless error.empty?
- unless staged.empty?
- sh "git", "-C", dump.to_s,
- "-c", "user.name=pro-tacts console", "-c", "user.email=console@localhost",
- "-c", "commit.gpgsign=false",
- "commit", "--message", "console snapshot #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}"
- end
-
exec "bundle", "exec", "irb", "-I", "lib", "-r", "./console"
end
diff --git a/tasks/db.rake b/tasks/db.rake
index 2813110..c55a15e 100644
--- a/tasks/db.rake
+++ b/tasks/db.rake
@@ -4,7 +4,14 @@
# groups/ are made to hold exactly what the store holds, so a dump kept
# in version control shows deletions too; anything else in the
# directory is left alone.
+#
+# The dump then commits what it wrote, into a repository of its own
+# beside the code's — `data/` is ignored here — so a snapshot can be
+# reverted to rather than overwritten by the next one
+# (docs/plans/2026-09-20-the-dump-commits.md). This is the one place
+# the tooling runs git; the app still runs none.
+require "open3"
require "pathname"
require "pro_tacts"
@@ -16,7 +23,7 @@ directory cards.to_s
directory groups.to_s
namespace :db do
- desc "Dump the cards, birthdays, and groups into #{dump} (DUMP=path to move it)"
+ desc "Dump the cards, birthdays, and groups into #{dump} and commit them there (DUMP=path to move it)"
task dump: [cards.to_s, groups.to_s] do
require "yaml"
require "pro_tacts/store"
@@ -36,6 +43,7 @@ namespace :db do
["#{group.id}.yml", YAML.dump({"name" => group.name, "lines" => group.lines, "members" => group.members})]
})
end
+ commit_dump(dump)
puts "dumped #{database} into #{dump}"
end
@@ -52,4 +60,28 @@ namespace :db do
def dump_file(path, content)
path.binwrite(content) unless path.exist? && path.binread == content.b
end
+
+ # The repository is the dump's own, made on the first dump. The
+ # identity is passed per invocation rather than read from the
+ # machine's git configuration, so a host that dumps needs none set up
+ # (docs/plans/2026-09-20-the-dump-commits.md).
+ def commit_dump(dump)
+ sh "git", "-C", dump.to_s, "init", "-b", "main" unless (dump / ".git").exist?
+ sh "git", "-C", dump.to_s, "add", "--all"
+
+ # Porcelain is the whole answer: empty means nothing to commit, so a
+ # dump that moved nothing adds no commit and the history is the
+ # writes rather than the runs. A failure here raises rather than
+ # leaving a caller believing the snapshot was recorded.
+ staged, error, = Open3.capture3("git", "-C", dump.to_s, "status", "--porcelain")
+ raise error unless error.empty?
+ return if staged.empty?
+
+ # The time is all a snapshot has to say for itself; what moved is
+ # the store's change log, which the dump deliberately leaves out.
+ sh "git", "-C", dump.to_s,
+ "-c", "user.name=pro-tacts", "-c", "user.email=dump@localhost",
+ "-c", "commit.gpgsign=false",
+ "commit", "--message", "dump #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}"
+ end
end
diff --git a/test/tasks/test_db.rb b/test/tasks/test_db.rb
index 2e19c33..4addc84 100644
--- a/test/tasks/test_db.rb
+++ b/test/tasks/test_db.rb
@@ -1,5 +1,6 @@
require_relative "../test_helper"
+require "open3"
require "pathname"
require "rake"
require "tmpdir"
@@ -46,6 +47,48 @@ class DbTasksTest < Minitest::Test
end
end
+ # The dump is a repository of its own, so the snapshot a console
+ # session starts from can be reverted to
+ # (docs/plans/2026-09-20-the-dump-commits.md).
+ def test_the_dump_commits_what_it_wrote
+ with_store do |store, root|
+ store.put("aiden", vcard(AIDEN))
+
+ dump(root)
+
+ assert_includes tracked(root / "dump"), "cards/aiden.vcf"
+ assert_equal 1, subjects(root / "dump").size
+ assert_match(/\Adump \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\z/, subjects(root / "dump").first)
+ end
+ end
+
+ # History is the writes rather than the runs, so dumping again over an
+ # unchanged store says nothing.
+ def test_a_dump_that_moved_nothing_adds_no_commit
+ with_store do |store, root|
+ store.put("aiden", vcard(AIDEN))
+ dump(root)
+
+ dump(root)
+
+ assert_equal 1, subjects(root / "dump").size
+ end
+ end
+
+ # What left the store leaves the repository too, rather than staying
+ # tracked at the revision it was deleted in.
+ def test_the_commit_drops_what_left_the_store
+ with_store do |store, root|
+ store.put("aiden", vcard(AIDEN))
+ dump(root)
+ store.delete("aiden")
+
+ dump(root)
+
+ refute_includes tracked(root / "dump"), "cards/aiden.vcf"
+ end
+ end
+
private
def with_store
@@ -68,10 +111,20 @@ class DbTasksTest < Minitest::Test
ProTacts.config = ProTacts::Config.new("PRO_TACTS_DATA_DIR" => root.to_s)
Rake.application = Rake::Application.new
load (Pathname.new(__dir__).parent.parent / "tasks" / "db.rake").to_s
- capture_io { Rake.application["db:dump"].invoke }
+ capture_subprocess_io { Rake.application["db:dump"].invoke }
ensure
ProTacts.config = config
Rake.application = application
ENV["DUMP"] = overridden
end
+
+ def tracked(dump) = git(dump, "ls-files")
+
+ def subjects(dump) = git(dump, "log", "--format=%s")
+
+ def git(dump, *arguments)
+ out, error, status = Open3.capture3("git", "-C", dump.to_s, *arguments)
+ raise error unless status.success?
+ out.lines(chomp: true)
+ end
end