Add git sync, and stop trusting the cached default branch
BuildEmpire/Totara renamed its default branch from main to totara-20. Nothing local noticed, because refs/remotes/origin/HEAD is written once at clone time and remote.<name>.followRemoteHEAD defaults to `create`, which only fills the ref in when it is missing. So `git default-branch` still said main, as did nvim's diff-against-branch prompt. bin/git-sync does the start-of-work sequence for a fork - fast-forward the default branch from upstream, push it to origin - and works out which branch that is by asking the server, not the cache. It sets the cached HEADs from the answer, so everything reading that ref agrees afterwards. It lives in bin/ rather than as an alias because git picks up git-<name> on the $PATH as a subcommand, and this is more shell than a gitconfig alias should hold. followRemoteHEAD = always is set for origin as well, so an ordinary fetch keeps the ref current without running the script. The nvim prompt now prefers origin, then upstream, then the remaining remotes. It took the first remote alphabetically before, which in be-edition means kdog - a colleague's fork - rather than anything authoritative. Verified against a fixture of bare repos whose default branch is totara-20 and whose cached HEAD is stale: the branch is created tracking origin when absent, fast-forwarded when behind, pushed to the fork, refuses to merge when the local copy has diverged, and is a no-op on a second run. The nvim prompt was checked in three real repos.
This commit is contained in:
parent
d4a4c68787
commit
80683b82c1
4 changed files with 137 additions and 11 deletions
28
README.md
28
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
|
> `~/.gitconfig.lock` and spew `error: could not lock config file`. Put git
|
||||||
> settings in `.gitconfig.local` instead.
|
> 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.<name>.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 `<leader>gm`
|
||||||
|
(diff-against-branch) tries `origin`, then `upstream`, then the rest.
|
||||||
|
|
||||||
## Light and dark mode
|
## Light and dark mode
|
||||||
|
|
||||||
Most of this is now handled natively and needs no configuration:
|
Most of this is now handled natively and needs no configuration:
|
||||||
|
|
|
||||||
67
bin/git-sync
Executable file
67
bin/git-sync
Executable file
|
|
@ -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/<remote>/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
|
||||||
16
gitconfig
16
gitconfig
|
|
@ -2,13 +2,18 @@
|
||||||
# list aliases
|
# list aliases
|
||||||
aliases = !git config --get-regexp 'alias.*' | colrm 1 6 | sed 's/[ ]/ = /' | sort
|
aliases = !git config --get-regexp 'alias.*' | colrm 1 6 | sed 's/[ ]/ = /' | sort
|
||||||
bdm = "!git branch --merged | grep -v '*' | xargs -n 1 git branch -d"
|
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'
|
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
|
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
|
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'
|
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
|
rank = shortlog -sn --no-merges
|
||||||
st = status -sb
|
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
|
tlog = log --stat --since='1 Day Ago' --graph --pretty=oneline --abbrev-commit --date=relative
|
||||||
|
|
||||||
[apply]
|
[apply]
|
||||||
|
|
@ -35,6 +40,15 @@
|
||||||
[pull]
|
[pull]
|
||||||
ff = only
|
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]
|
[core]
|
||||||
pager = delta --side-by-side --width ${FZF_PREVIEW_COLUMNS-$COLUMNS}
|
pager = delta --side-by-side --width ${FZF_PREVIEW_COLUMNS-$COLUMNS}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,35 @@ map('<leader>gd', '<Cmd>CodeDiff<CR>', 'Diff working tree')
|
||||||
map('<leader>gh', '<Cmd>CodeDiff history %<CR>', 'File history')
|
map('<leader>gh', '<Cmd>CodeDiff history %<CR>', 'File history')
|
||||||
map('<leader>gH', '<Cmd>CodeDiff history<CR>', 'Branch history')
|
map('<leader>gH', '<Cmd>CodeDiff history<CR>', 'Branch history')
|
||||||
|
|
||||||
-- The base branch name varies per repo (main, master, develop, ...), so ask
|
-- A remote's cached HEAD, or nil if it has none. Kept current by
|
||||||
-- the first remote's HEAD what it is rather than hardcoding one.
|
-- 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 function default_branch()
|
||||||
local remote = vim.fn.systemlist('git remote')[1]
|
local candidates = { 'origin', 'upstream' }
|
||||||
if not remote or remote == '' then
|
vim.list_extend(candidates, vim.fn.systemlist('git remote'))
|
||||||
return ''
|
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
|
end
|
||||||
return ref:gsub('^refs/remotes/' .. remote .. '/', '')
|
return ''
|
||||||
end
|
end
|
||||||
|
|
||||||
map('<leader>gm', function()
|
map('<leader>gm', function()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue