git / workflow
For each change to my software,
I add or update a plan in the project's docs/plans.
Each plan is committed in version control
and may describe a feature, bug, or chore.
This shifts planning and review left:
the scope, risks, and rollout plan are explicit
before implementation starts.
Start
I draft or refine the plan, often with agents. That is fast and complete because the agent can inspect the codebase and local development data (see postgres / dump prod restore dev).
Then I create a worktree:
createtree
On a repo hosted by cibot,
this 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,
so naming happens once, on the server.
On a GitHub repo it falls back to git create-tree my-branch,
where the branch name is mine to invent.
It 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_development
pg migrated 20260820094830
pg reused app_test_aa3d3fbfaa8bd8e4e50463d0ae1ab236
The worktree comes first, then what is inside it.
The port is there because each checkout runs its own web server,
and the database because each checkout gets its own copy
(see postgres / dev test clusters).
Everything but the path goes to stderr,
so the shell function that owns the cd reads a path and nothing else.
The main repo directory stays on main, untouched.
I can have multiple worktrees for different tasks at the same time,
which is useful when AI agents are working in parallel
or when I'm waiting on review for one change
while starting another.
I edit the code and commit the changes to version control:
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 is the whole trigger:
it advances the change
and queues its CI checks.
There is no second command to open anything.
A change pushed at a single commit
takes that commit's subject and body as its title and description,
so a well-written first commit needs no cibot edit.
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, it names where the action is rather than listing them. I keep subjects under 50 characters, wrap the body at 72, and spend the body on why, since the diff already says what. I follow Chris Beams' commit message conventions for the rest.
Since the first commit's subject becomes the change's title, and the title becomes the squash commit's subject, the same convention is what the merge gate checks. 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 plain-text 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 alongside 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 separate approve verb: a reviewer who means yes writes it, since nothing counts verdicts and the merge gate is the checks. The web page shows the same change with the diff and the checks, read-only, and refreshes itself as writes land.
I make follow-up changes and push again. We may do this once, or multiple times.
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)
pg dropped app_dev_app_42
main updated to 0062060
The same shape as createtree, in reverse.
Dropping the database here is what makes the disk come back
when the worktree goes,
rather than whenever I next start work.
The farmer squashes the change onto main.
It refuses a change that is untitled,
that conflicts,
or whose checks are not green,
so the gate is on the server rather than in a branch protection setting.
The commit message is the change's title and description,
with Co-Authored-By collected from the commits being squashed
and Reviewed-by from the comments.
The farmer then pushes main to the GitHub mirror,
which is what 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.
Comparing it against origin is what picks the branch:
where cibot is origin, the farmer names the change.
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
} >&2
echo "$tree_dir"
The path is the only thing on stdout,
so the shell function that owns the cd gets a path and nothing else.
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"
if [ -d "$main/cmd/db" ]; then
(cd "$main" && go run ./cmd/db prune) ||
echo "prune: left this worktree's database behind"
fi
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)"
} >&2
echo "$main"
It reads the branch from where it is run
rather than taking an argument,
and refuses to delete main.
The git commands run quiet
and the script says what it did instead.
Letting all five of them print
meant a deleted remote-tracking ref, a fast-forward, and a diffstat:
nine lines about work I did not ask to watch,
and no line naming the worktree that went.
The commit is abbreviated to seven characters
to match the merge line printed just above it,
since git's --short is adaptive
and the two disagreed about the length of the same hash.
The cmd/db guard is how a repo that gives each worktree its own
database gets that database dropped.
It is the same test the checkout that creates one uses,
and it is reported rather than fatal:
the worktree is gone either way,
and a laptop with no Postgres running
is a thing to say rather than a reason to leave a branch behind.