git / workflow
For each change to my software,
I add or update a plan in the project's docs/plans.
I commit each plan to version control.
A plan describes a feature, a bug, or a chore.
It states the scope, the risks, and the rollout
before I start to implement.
Start
I draft or refine the plan, often with agents. The agent can inspect the codebase and local development data (see postgres / dev test clusters).
Then I create a worktree:
createtree
On a repo hosted by sockeye,
createtree allocates a change,
cuts a worktree at ~/.worktrees/<repo>/<ID>,
and cds into it.
The change id is the branch name and the directory name.
On a GitHub repo it runs git create-tree my-branch,
and I name the branch.
createtree reports a line per thing it set up:
worktree created APP-42 (/Users/me/.worktrees/app/APP-42)
env set PORT=3001 (.env.local)
pg created app_dev_app_42 (.db) from app_dev_main
pg reused app_test_aa3d3fbfaa8bd8e4e50463d0ae1ab236
The lines after the first come from the repo's tree.setup script
(see what the repo needs of a worktree).
Each checkout runs its own web server, so it gets a port.
Each checkout gets its own copy of the database
(see postgres / dev test clusters).
A repo that needs neither sets no tree.setup key.
The main repo directory stays on main.
I can have several worktrees for different tasks at the same time.
That helps when agents work in parallel,
or when I wait on review for one change
while I start another.
I edit the code and commit:
git aa
git ci
Those are aliases in ~/.gitconfig:
[alias]
aa = add --all
ci = commit --verbose
I push:
soc push --wait
soc push --wait advances the change,
queues its CI checks,
and reports the verdict.
Commit messages
Every subject starts with the area that is changing:
server: gate merges on a subject convention
scripts: remove node from runner provisioning
ui: say "not updating" once, not per section
The prefix usually matches a directory. When a change touches several, the prefix names where the action is. I keep subjects under 50 characters, wrap the body at 72, and spend the body on why. I follow Chris Beams' commit message conventions for the rest.
When I push a change at a single commit, the change takes that commit's subject and body as its title and description. The merge makes them the squash commit's subject and body. The merge gate checks the same convention. See cmd / sockeye.
Change descriptions
The description is how a teammate learns why the code is the way it is. Agents now write most descriptions, so the rules file says how.
I give the agent a reader: an engineer on their first day, a year from now. They know the language and the database. They do not know the project's terms. They did not see the conversation, and they do not have the diff open.
I give the agent an order, as in a news story. A reader who stops after any paragraph has the most important part so far:
- The need, in product words.
- What users see now, with each page named.
- How it works. Each table, job, and page gets its kind at first
mention: "the
jobsPostgres table". - Next steps: a command to run after the deploy, or a plan by its path.
- Trailers.
Most changes fit in three to five short paragraphs. The description leaves out file lists, rejected options, and "tests pass". A design argument goes in a code comment or a plan.
Who asked for the work goes in a Requested-by: trailer, with the
person, the place, and the date. The agent writes it only when
somebody other than the author asked. Before this rule, more than
half of one repo's recent descriptions opened with "Dan asked in this
agent conversation", which names nothing after the merge. A
production error that the change fixes goes in a Fixes: trailer,
with its error-tracker id.
The rules file used to tell an agent to read git log for examples.
Each agent copied the latest style, so a bad description spread.
Now the rules file holds one example, written to the rules.
Review
I inspect the change the way a reviewer will:
soc show
soc show prints the title and description,
the status, the author,
whether it still applies to main,
and a row per check with a URL.
When a check fails I paste its output
into the agent in that worktree.
Then I ask a teammate in Slack:
@buddy PTAL APP-42
"PTAL" means "Please Take A Look".
They check out the change with soc checkout APP-42,
their agent reviews it,
and they post one comment with no approval state
(see cmd / sockeye
and ai / review).
A reviewer who means yes writes it.
I make follow-up changes and push again.
Merge
mergetree
That runs soc merge, then removes the worktree
and returns me to the main repo directory on an updated main:
merged APP-42 as 0062060
worktree removed APP-42 (/Users/me/.worktrees/app/APP-42)
main updated to 0062060
pg dropped app_dev_app_42
pg migrated 20260820094830
The last two lines come from the repo's tree.teardown script,
which deletetree runs in the main checkout once main is up to date.
The drop frees the disk when the worktree goes.
The migration brings the main checkout's database
to the merged commit.
The sockeye server squashes the change onto main
when its merge gate passes
(see cmd / sockeye).
For a repo with a GitHub mirror,
the server then pushes main there,
which deploys to my staging environment on
Render.
I acceptance test on staging, then deploy to production with a deploy script:
go run ./cmd/deploy
I update the plan with outcomes and follow-ups.
Functions
createtree, deletetree, and mergetree are zsh functions
so they can cd in the current shell.
Each one wraps a program that prints a path and nothing else:
# Match registered servers, not the global default alone.
_tree_server() {
local origin key url
origin=$(git config --get remote.origin.url 2>/dev/null) || return 1
while read -r key url; do
if [[ -n "$url" && "$origin" == "${url%/}"/git/* ]]; then
return 0
fi
done < <(git config --get-regexp '^sockeye(\.[^.]+)?\.url$' 2>/dev/null)
return 1
}
# Pin tree commands to origin, even when a URL is exported.
_tree_change() {
local origin
_tree_server || {
print -ru2 -- "Origin is not a configured sockeye server."
return 1
}
origin=$(git config --get remote.origin.url) || return
SOCKEYE_URL="${origin%%/git/*}" command soc "$@"
}
createtree() {
local dir
if _tree_server; then
dir=$(_tree_change checkout "$@") || return
else
dir=$(git create-tree "$@") || return
fi
cd "$dir"
}
deletetree() {
local main
main=$(git delete-tree) || return
cd "$main"
}
mergetree() {
_tree_change merge "$@" || return
deletetree
}
I use two sockeye servers: a personal one and one at work.
The URL of each is in ~/.gitconfig.local,
which stays out of my dotfiles repo:
[sockeye]
url = https://personal.example.com
[sockeye "dc"]
url = https://personal.example.com
[sockeye "ivp"]
url = https://work.example.com
createtree and mergetree compare origin against every one,
and send the command to the server that hosts the clone.
They set SOCKEYE_URL from origin for that one command.
So a URL I exported for another server
cannot send a merge to the wrong place.
A clone on no sockeye server falls through to git create-tree.
For one command against a named server,
outside any clone,
soc-dc and soc-ivp set SOCKEYE_URL the same way:
soc-ivp login
soc-dc show SOC-1
git-create-tree is a script on $PATH
for repos sockeye does not host:
#!/bin/sh
set -e
case "${1:-}" in
"" | --*)
echo "usage: git create-tree branch-name" >&2
exit 1
;;
esac
if [ "$(git branch --show-current)" != "main" ]; then
echo "Error: must be on main" >&2
exit 1
fi
username=$(git config --get github.user || whoami)
main_dir=$(git rev-parse --show-toplevel)
tree_dir="$HOME/.worktrees/$(basename "$main_dir")/$1"
{
git pull
git worktree add -b "$username/$1" "$tree_dir" origin/main
# Worktrees don't share gitignored files with the main working tree
if [ -e "$main_dir/.env" ]; then
ln -s "$main_dir/.env" "$tree_dir/.env"
fi
setup=$(git config --get tree.setup || true)
if [ -n "$setup" ]; then
(cd "$tree_dir" && sh -c "$setup") ||
echo "tree.setup: failed"
fi
} >&2
echo "$tree_dir"
The path is the only thing on stdout. Everything git prints goes to stderr.
git-delete-tree drops the current worktree
and its branch,
leaves the main checkout on an up-to-date main,
and prints that checkout's path:
#!/bin/sh
set -e
if [ $# -ne 0 ]; then
echo "usage: git delete-tree (run it from inside the worktree)" >&2
exit 1
fi
main=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")
tree=$(git rev-parse --show-toplevel)
branch=$(git branch --show-current)
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
echo "Error: refusing to delete $branch" >&2
exit 1
fi
{
if [ "$tree" = "$main" ]; then
# The branch was never given a worktree of its own.
git -C "$main" checkout -q main
echo "branch removed $branch"
else
git -C "$main" worktree remove "$tree"
echo "worktree removed $(basename "$tree") ($tree)"
fi
git -C "$main" branch -q -D "$branch"
git -C "$main" fetch -q origin
git -C "$main" merge -q --ff-only origin/main
git -C "$main" remote prune origin
echo "main updated to $(git -C "$main" rev-parse --short=7 HEAD)"
teardown=$(git -C "$main" config --get tree.teardown || true)
if [ -n "$teardown" ]; then
(cd "$main" && sh -c "$teardown") ||
echo "tree.teardown: failed"
fi
} >&2
echo "$main"
The git commands run quiet, and the script reports one line per thing it did.
What the repo needs of a worktree
Neither script knows what a worktree of a given repo needs, so the clone says. Two git config keys, set once per clone, name a command each:
git config tree.setup bin/tree-setup
git config tree.teardown bin/tree-teardown
I use config so reviewing somebody else's branch does not run their code.
For the repo with a database per worktree,
bin/tree-setup is one line:
go run ./cmd/db newdb
bin/tree-teardown drops the removed worktree's database,
then migrates the main checkout's:
go run ./cmd/db prune ||
echo "prune: left the worktree's database behind"
schema="db/schema.sql"
dirty=$(git status --porcelain -- "$schema")
go run ./cmd/db migrate ||
echo "migrate: main's database is behind main"
if [ -z "$dirty" ] && [ -n "$(git status --porcelain -- "$schema")" ]; then
git checkout -q -- "$schema"
echo "migrate: $schema came back changed; put it back"
fi
The main checkout's database is the template every new worktree copies, so the teardown migrates it to the merged commit.
Migrating regenerates the schema dump. A dump that differs from the committed one leaves the checkout dirty, so the script puts it back and says so.
A failed step reports and the script continues. The branch is merged and gone either way.