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.

← All articles