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 cibot,
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.
A tree.setup key in the clone's git config names a script,
and createtree runs it in the new worktree:
git config tree.setup bin/tree-setup
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 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:
git push
origin is cibot's self-hosted git,
and it is the only remote in the clone.
The push advances the change
and queues its CI checks.
A change pushed at a single commit takes that commit's subject and body as its title and description.
Commit messages
Every subject starts with the area that is changing:
farmer: gate merges on a subject convention
scripts: remove node from worker 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.
The first commit's subject becomes the change's title, and the title becomes the squash commit's subject. The merge gate checks the same convention. See cmd / cibot.
Review
I read the change the way a reviewer will:
cibot show
git diff origin/main...HEAD
cibot 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 review from their own terminal.
cibot checkout APP-42 gives them a worktree of my change,
their agent reads the diff with them,
and they post one comment at the end:
echo "the retry loop needs a ceiling" | cibot comment APP-42
One comment per review, no threading. There is no approve verb. A reviewer who means yes writes it. The web page shows the same change with the diff and the checks, read-only.
I make follow-up changes and push again.
Merge
mergetree
That runs cibot 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 applies the merged commit
to the main checkout's database,
which is the template the next worktree copies.
The farmer squashes the change onto main.
The farmer refuses a change that is untitled,
that conflicts,
or whose checks are not green.
The commit message is the change's title and description.
The farmer collects Co-Authored-By from the squashed commits
and Reviewed-by from the comments.
The farmer then pushes main to the GitHub mirror,
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:
createtree() {
local dir origin farmer
origin=$(git config --get remote.origin.url)
farmer=$(git config --get cibot.url)
if [[ -n "$farmer" && "$origin" == "$farmer"/git/* ]]; then
dir=$(cibot checkout "$@") || return
else
dir=$(git create-tree "$@") || return
fi
cd "$dir"
}
deletetree() {
local main
main=$(git delete-tree) || return
cd "$main"
}
mergetree() {
cibot merge "$@" || return
deletetree
}
cibot.url is in my global gitconfig,
so the same dotfiles work on a cibot repo and a GitHub one.
The function compares it against origin to pick the branch.
git-create-tree is a script on $PATH
for repos cibot 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 worktree you are standing in
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"
git-delete-tree reads the branch from the current directory
and refuses to delete main.
The git commands run quiet.
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.