diff --git a/README.md b/README.md index 0cb8c9b..d69a042 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,34 @@ variables, sourced from `.zshrc`. Keep git out of it: > `~/.gitconfig.lock` and spew `error: could not lock config file`. Put git > settings in `.gitconfig.local` instead. +## Fork workflow + +Work repos are forks: `origin` is mine, `upstream` is the one PRs are raised +against. `git sync` — `bin/git-sync`, found as a subcommand because it is on the +`$PATH` — does the start-of-work dance: + +``` +git sync # fast-forward the default branch from upstream, push it to origin +``` + +It asks the server which branch is the default rather than assuming `main`, +fast-forwards only (a diverged default branch is something to look at, not to +merge), and pushes to `origin` so the fork's copy matches. Then branch off it as +usual, push the branch to `origin`, and raise the PR against `upstream`. + +The awkward part is that **git caches the default branch and does not refresh +it**. `refs/remotes/origin/HEAD` is written once, at clone time; the default +`remote..followRemoteHEAD = create` only fills it in when missing. So +BuildEmpire/Totara renaming its default from `main` to `totara-20` left every +clone still reporting `main` — `git default-branch` included. Two things fix +that: `followRemoteHEAD = always` in the gitconfig re-points the ref on every +fetch, and `git sync` sets it explicitly from what the server just said. + +Anything wanting the base branch should read that ref, and prefer `origin` when +doing so. `git remote` sorts alphabetically, so picking the first remote in +be-edition returns `kdog` — a colleague's fork. nvim's `gm` +(diff-against-branch) tries `origin`, then `upstream`, then the rest. + ## Light and dark mode Most of this is now handled natively and needs no configuration: diff --git a/bin/git-sync b/bin/git-sync new file mode 100755 index 0000000..0675323 --- /dev/null +++ b/bin/git-sync @@ -0,0 +1,67 @@ +#!/usr/bin/env zsh + +# Bring the default branch up to date in a fork-model repo, and keep my fork's +# copy of it aligned: fast-forward it from whichever remote owns it, then push +# it to origin. Named git-sync and installed on the $PATH, so git finds it as +# `git sync`. + +set -e + +git rev-parse --git-dir > /dev/null + +# The branch belongs to upstream in a fork, and to origin when there is no +# fork in play. +if git remote get-url upstream > /dev/null 2>&1; then + source_remote=upstream +else + source_remote=origin +fi + +if ! git remote get-url origin > /dev/null 2>&1; then + echo "git sync: no 'origin' remote to sync" >&2 + exit 1 +fi + +# Ask the server which branch is the default rather than reading +# refs/remotes//HEAD. That ref is a cache written at clone time, and +# git does not refresh it when the default branch changes upstream - which is +# the whole reason this script exists. +branch=$(git ls-remote --symref $source_remote HEAD | + awk '/^ref:/ { sub("refs/heads/", "", $2); print $2; exit }') + +if [[ -z $branch ]]; then + echo "git sync: could not work out the default branch of $source_remote" >&2 + exit 1 +fi + +echo "Syncing $branch from $source_remote" + +git fetch --prune $source_remote + +# Re-point the cached HEADs at what the server just told us, so `git +# default-branch` and nvim's diff-against-branch prompt agree with this script. +git remote set-head origin --auto > /dev/null +if [[ $source_remote != origin ]]; then + git remote set-head $source_remote --auto > /dev/null +fi + +# Be explicit about what the local branch tracks. A bare `git switch` has to +# guess, and refuses to when several remotes carry the same branch name - which +# they do as soon as a colleague's fork is added as a remote. +if git show-ref --verify --quiet refs/heads/$branch; then + git switch $branch +elif git show-ref --verify --quiet refs/remotes/origin/$branch; then + # Track origin, not upstream: this script keeps the fork's copy current, and + # it means a stray `git push` here goes somewhere I can actually write to. + git switch --create $branch --track origin/$branch +else + git switch --create $branch --track $source_remote/$branch +fi + +# Never a merge commit: if the default branch has diverged locally, that is +# something to look at, not something to paper over. +git merge --ff-only $source_remote/$branch + +if [[ $source_remote != origin ]]; then + git push origin $branch +fi diff --git a/gitconfig b/gitconfig index 440297a..c4360af 100644 --- a/gitconfig +++ b/gitconfig @@ -2,13 +2,18 @@ # list aliases aliases = !git config --get-regexp 'alias.*' | colrm 1 6 | sed 's/[ ]/ = /' | sort bdm = "!git branch --merged | grep -v '*' | xargs -n 1 git branch -d" - default-branch = "!git symbolic-ref refs/remotes/origin/HEAD | cut -d'/' -f4" + # Reads origin's cached HEAD, which `git sync` and remote.origin.followRemoteHEAD + # below keep pointing at whatever the server currently calls its default. + # --short over cut -d/ -f4, which mangles names like release/1.0. + default-branch = "!git symbolic-ref --short refs/remotes/origin/HEAD | sed 's|^origin/||'" lg = log --graph --pretty=tformat:'%Cred%h%Creset -%C(auto)%d%Creset %s %Cgreen(%an %ar)%Creset' lg2 = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold cyan)%aD%C(reset) %C(bold green)(%ar)%C(reset)%C(bold yellow)%d%C(reset)%n' %C(white)%s%C(reset) %C(dim white)- %an%C(reset)' --all lastchange = log -p --follow -n 1 plog = log --graph --pretty='format:%C(red)%d%C(reset) %C(yellow)%h%C(reset) %ar %C(green)%aN%C(reset) %s' rank = shortlog -sn --no-merges st = status -sb + # `git sync` is not here - it is bin/git-sync, on the $PATH, which git picks + # up as a subcommand. `git aliases` above will not list it. tlog = log --stat --since='1 Day Ago' --graph --pretty=oneline --abbrev-commit --date=relative [apply] @@ -35,6 +40,15 @@ [pull] ff = only +[remote "origin"] + # refs/remotes/origin/HEAD is written once at clone time, and the default + # `create` only fills it in when missing - so a default branch renamed on the + # server (main -> totara-20) leaves every tool reading that ref pointing at a + # branch that may not even exist. `always` re-points it on each fetch, off the + # ref advertisement that fetch already receives, so it costs no extra round + # trip. Only applies to remotes actually named origin. + followRemoteHEAD = always + [core] pager = delta --side-by-side --width ${FZF_PREVIEW_COLUMNS-$COLUMNS} diff --git a/neovim/config/plugins/codediff.lua b/neovim/config/plugins/codediff.lua index 80775fe..5245f96 100644 --- a/neovim/config/plugins/codediff.lua +++ b/neovim/config/plugins/codediff.lua @@ -16,18 +16,35 @@ map('gd', 'CodeDiff', 'Diff working tree') map('gh', 'CodeDiff history %', 'File history') map('gH', 'CodeDiff history', 'Branch history') --- The base branch name varies per repo (main, master, develop, ...), so ask --- the first remote's HEAD what it is rather than hardcoding one. +-- A remote's cached HEAD, or nil if it has none. Kept current by +-- remote.origin.followRemoteHEAD in the gitconfig and by `git sync`; without +-- those this is whatever the default branch was at clone time. +local function remote_head(remote) + local prefix = 'refs/remotes/' .. remote .. '/' + -- --quiet so a remote with no cached HEAD stays silent rather than having its + -- error text come back as the branch name. + local ref = vim.fn.systemlist('git symbolic-ref --quiet ' .. prefix .. 'HEAD')[1] + if not ref or ref:sub(1, #prefix) ~= prefix then + return nil + end + return ref:sub(#prefix + 1) +end + +-- The base branch name varies per repo (main, master, totara-20, ...), so ask a +-- remote rather than hardcoding one. origin first: in the fork workflow that is +-- my fork, and `git sync` keeps its default branch level with upstream's. Only +-- then upstream, then whatever remotes exist - `git remote` sorts them +-- alphabetically, so first-available on its own can hand back a colleague's fork. local function default_branch() - local remote = vim.fn.systemlist('git remote')[1] - if not remote or remote == '' then - return '' + local candidates = { 'origin', 'upstream' } + vim.list_extend(candidates, vim.fn.systemlist('git remote')) + for _, remote in ipairs(candidates) do + local branch = remote_head(remote) + if branch then + return branch + end end - local ref = vim.fn.systemlist('git symbolic-ref refs/remotes/' .. remote .. '/HEAD')[1] - if not ref or ref == '' then - return '' - end - return ref:gsub('^refs/remotes/' .. remote .. '/', '') + return '' end map('gm', function()