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:

  1. The need, in product words.
  2. What users see now, with each page named.
  3. How it works. Each table, job, and page gets its kind at first mention: "the jobs Postgres table".
  4. Next steps: a command to run after the deploy, or a plan by its path.
  5. 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.

← All articles