git / workflow
For each change to my software,
I add or update a plan in ~/EDS/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.
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.
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, the approvals,
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
cibot approve APP-42
One comment per review, no threading. 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.
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 main
else
git -C "$main" worktree remove "$tree"
fi
git -C "$main" branch -D "$branch"
git -C "$main" fetch origin
git -C "$main" merge --ff-only origin/main
git -C "$main" remote prune origin
} >&2
echo "$main"
It reads the branch from where it is run
rather than taking an argument,
and refuses to delete main.