Detect Git branch state without breaking in detached HEAD
Replace brittle parsing with Git plumbing, distinguish branch, detached, unborn, and invalid states, and keep release automation recoverable.
A release script asks Git for the current branch and receives an empty line. The tempting conclusion is that Git is broken. Often Git is describing the repository accurately: a CI runner checked out a commit, a bisect is in progress, or an operator selected a tag, so HEAD points directly to a commit instead of to a branch. “No current branch” is a valid repository state, not a string-parsing failure.
Model the states before choosing a command
| State | Meaning | Safe automation response |
|---|---|---|
| Attached branch | HEAD symbolically refers to refs/heads/… | Use the branch name. |
| Detached HEAD | HEAD contains a commit ID | Use the commit ID; do not invent a branch. |
| Unborn branch | A symbolic branch exists but has no commit yet | Accept the name, but do not resolve HEAD as an object. |
| Not a repository | No applicable worktree/repository was found | Fail with path/context evidence. |
This distinction prevents a dangerous fallback such as silently labeling a detached build main.
The human command and the script command are different
The accepted Stack Overflow answer by roberttdev shows git branch for a human and git rev-parse --abbrev-ref HEAD for a compact result, then notes the newer git branch --show-current. Current Git documentation defines --show-current as printing the current branch name and printing nothing in detached HEAD:
git branch --show-current
That is excellent for display, but an empty successful output is easy for shell automation to confuse with a command failure. When state matters, query the symbolic reference explicitly:
if branch=$(git symbolic-ref --quiet --short HEAD); then
printf 'branch=%s\n' "$branch"
else
rc=$?
case "$rc" in
1) printf 'detached_commit=%s\n' "$(git rev-parse --verify HEAD)" ;;
*) printf 'cannot inspect HEAD (status %s)\n' "$rc" >&2
exit "$rc" ;;
esac
fi
The official git-symbolic-ref documentation assigns status 1 when the name is not symbolic and 128 to other errors. Preserve that distinction.
Diagnose the repository you actually opened
git --version
git rev-parse --show-toplevel
git rev-parse --is-inside-work-tree
git status --short --branch
git symbolic-ref --quiet HEAD; printf 'symbolic-ref rc=%s\n' "$?"
git rev-parse --verify HEAD
Run these with the same cwd and environment as the failing job. In a monorepo, submodule, linked worktree, or container bind mount, the script may be inspecting a different repository than the operator. Avoid parsing localized git status output or looking directly for .git: worktrees can use a .git file, and environment variables such as GIT_DIR and GIT_WORK_TREE can redirect discovery.
Return structured identity, not one overloaded string
repo_identity() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 2
if name=$(git symbolic-ref --quiet --short HEAD); then
if git rev-parse --verify HEAD >/dev/null 2>&1; then
printf '{"state":"branch","name":"%s"}\n' "$name"
else
printf '{"state":"unborn","name":"%s"}\n' "$name"
fi
return 0
fi
if oid=$(git rev-parse --verify HEAD 2>/dev/null); then
printf '{"state":"detached","commit":"%s"}\n' "$oid"
return 0
fi
printf '{"state":"invalid"}\n'
return 3
}
In real code, serialize with a JSON library rather than printf if names are not tightly controlled. A branch name cannot contain many hazardous characters, but output encoding should still be deliberate.
A subtle ordering issue with unborn branches
A freshly initialized repository can have HEAD symbolically naming a branch even though no commit object exists. Therefore ask symbolic-ref before requiring rev-parse --verify HEAD when the branch name is useful. If the workflow needs a commit—for a build label or artifact provenance—fail clearly until the first commit exists.
CI systems commonly detach on purpose
Build systems often check out an exact commit to make a run reproducible. Their branch or pull-request variables are orchestration metadata, not proof that Git HEAD is attached to that branch. Record both:
source_ref="${CI_SOURCE_REF:-}"
commit=$(git rev-parse --verify HEAD)
branch=$(git symbolic-ref --quiet --short HEAD || true)
printf 'source_ref=%s commit=%s checked_out_branch=%s\n' "$source_ref" "$commit" "$branch"
Never run git checkout "$source_ref" merely to make the branch query nonempty. That changes the worktree and may build a different commit if the branch moved.
Verification matrix
tmp=$(mktemp -d)
git -C "$tmp" init
# Expect: symbolic branch exists, but HEAD cannot yet verify.
git -C "$tmp" config user.name Test
git -C "$tmp" config user.email test@example.invalid
git -C "$tmp" commit --allow-empty -m first
# Expect: attached branch and a commit.
oid=$(git -C "$tmp" rev-parse HEAD)
git -C "$tmp" switch --detach "$oid"
# Expect: detached state and the same commit.
Add cases for a linked worktree, a path outside any repository, a shallow clone, and a branch name containing a slash. Test the exact Git versions in supported build images; git branch --show-current was introduced in Git 2.22.
Failure modes worth resisting
- Parsing the asterisk:
git branch | grep '*'is presentation parsing and behaves poorly in detached state. - Treating
HEADas a branch:rev-parse --abbrev-ref HEADcan return the literal wordHEADwhen detached. - Guessing from containment: several branches can contain the same commit; containment does not identify which branch was checked out.
- Trusting a tag as a branch: a tag can describe a commit, but it does not restore branch attachment.
- Suppressing every error:
|| truemakes “detached” indistinguishable from “wrong directory” or repository corruption.
Recovery and production trade-offs
If a release requires an attached branch, stop before producing or publishing artifacts and print the commit plus the expected source reference. Recovery is to restart from a clean checkout with an explicit ref, not mutate the ambiguous workspace in place. Preserve any local changes with a patch or separate worktree before corrective checkout.
Branch names are friendly but mutable. Commit IDs are stable but less readable. Use the commit as the provenance and cache key; attach branch, tag, or CI source ref as optional labels. During rollback, redeploy a known artifact by immutable commit and digest rather than asking whichever commit a branch currently names.
Detached HEAD is not missing information that Git can safely guess. It is a different state your automation must represent.
Sources: the Stack Overflow question by user654460 and accepted answer by roberttdev (CC BY-SA), and the current Git documentation for git-branch, git-symbolic-ref, and git-rev-parse.
Primary source: Review the official reference ↗