← Blog
Product Engineering

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.

By Emmanuel Corels

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

StateMeaningSafe automation response
Attached branchHEAD symbolically refers to refs/heads/…Use the branch name.
Detached HEADHEAD contains a commit IDUse the commit ID; do not invent a branch.
Unborn branchA symbolic branch exists but has no commit yetAccept the name, but do not resolve HEAD as an object.
Not a repositoryNo applicable worktree/repository was foundFail 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 HEAD as a branch: rev-parse --abbrev-ref HEAD can return the literal word HEAD when 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: || true makes “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 ↗