How Do I Recover from a Detached HEAD State in Git?
How do I recover from a detached HEAD state in Git?
TL;DR
- Bottom line: Detached HEAD means
HEADpoints directly to a commit instead of a branch name. It is not an error. The danger: new commits made in this state are not on any branch and may be garbage-collected. Fix: if no new commits, justgit switch <branch>. If you have new commits to keep, create a branch first:git switch -c <new-branch>. - Key tool/command:
git switch -c <new-branch>— saves all detached-HEAD commits to a new branch without losing them. - Watch out for: Switching away from detached HEAD without creating a branch
first causes those commits to become unreachable (orphaned). You have ~30 days to recover them
via
git reflogbefore garbage collection. - Works with: Git 2.23+ (
git switch). For older Git:git checkout -b <branch>. Latest stable: Git 2.53 (Feb 2026).
Constraints
- Never switch away without saving: If you made commits in detached HEAD, always run
git switch -c <branch>before switching to another branch. Orphaned commits are garbage-collected after ~30 days and become permanently unrecoverable. [src1, src3] - Git 2.23+ required for git switch: On older systems (RHEL 7, CentOS 7, some enterprise
Linux distributions),
git switchis not available. Usegit checkout -bas the equivalent fallback. [src1] - Do not “fix” detached HEAD in CI/CD: Pipelines (GitHub Actions, Jenkins, GitLab CI) check out a specific SHA for reproducibility. Detached HEAD is the expected and correct behavior. Do not create branches or attempt recovery. [src5]
- Never run git gc or git prune during recovery: These commands may permanently destroy
orphaned commits you are trying to recover. Complete recovery first, verify via
git log, then allow GC to proceed normally. [src2, src8] - Shallow clones limit recovery: CI/CD systems using
--depth=1have very limited reflog history. Orphaned commits in shallow clones may be unrecoverable via reflog — usegit fsck --lost-foundas a last resort. [src5]
Quick Reference
| # | Goal | Command | Notes |
|---|---|---|---|
| 1 | Check if in detached HEAD | git status |
Shows “HEAD detached at <hash>” [src1] |
| 2 | See current HEAD position | git log --oneline -5 |
Top commit = where HEAD is [src1] |
| 3 | Save new commits → new branch | git switch -c <new-branch> |
Best recovery path; Git 2.23+ [src1, src3] |
| 4 | Save new commits → new branch (old Git) | git checkout -b <new-branch> |
Equivalent for Git < 2.23 [src1] |
| 5 | Go back to branch (no new commits) | git switch <branch> |
Discards detached state cleanly [src1] |
| 6 | Cherry-pick detached commits to existing branch | git cherry-pick <hash> |
Copies specific commits to current branch [src1] |
| 7 | Find lost commits after switching away | git reflog |
Shows all HEAD positions for ~30 days [src2] |
| 8 | Recover lost commit by hash | git branch recover-work <hash> |
Create branch at recovered commit [src1, src2] |
| 9 | Inspect a historical commit (intentional) | git switch --detach <hash> |
Safe read-only view of history [src1, src5] |
| 10 | Check out a tag (enters detached HEAD) | git checkout v1.2.3 |
Tags are immutable; always detaches [src1, src6] |
| 11 | Check out remote branch (enters detached HEAD) | git checkout origin/main |
Use git switch main instead [src1] |
| 12 | Create local tracking branch from remote | git switch -c main origin/main |
Fixes “accidentally detached on origin/main” [src1, src3] |
| 13 | Clone at specific revision (intentional detach) | git clone --revision <ref> <url> |
Git 2.49+; no tracking branch, HEAD detached [src1] |
Decision Tree
START — “HEAD detached at <hash>” in git status
├── Did you make any NEW commits in the detached state?
│ ├── NO → Just switch back to your branch:
│ │ git switch main [src1]
│ │ Nothing is lost.
│ └── YES → Save those commits FIRST:
│ git switch -c my-new-feature [Git 2.23+, preferred]
│ git checkout -b my-new-feature [Git < 2.23]
│ Then merge/rebase into target branch as needed.
│
├── Already switched away and lost the commits?
│ ├── Find them: git reflog
│ └── Recover: git branch recover-work <commit-hash>
│ git switch recover-work [src2]
│
├── WHY did you end up detached?
│ ├── git checkout <commit-hash> → inspecting history (expected)
│ ├── git checkout <tag> → tags always detach [src6]
│ ├── git checkout origin/main → use git switch main instead [src3]
│ ├── git clone --revision <ref> → intentional minimal clone, Git 2.49+ [src1]
│ └── CI/CD pipeline → shallow SHA checkout (expected) [src5]
│
└── Prevent accidental detached HEAD?
└── Prefer git switch over git checkout for branch navigation [src1]
Step-by-Step Guide
1. Understand what detached HEAD means
Normally HEAD → branch pointer → commit. In detached HEAD, HEAD →
commit directly.
Commits made here are not on any branch and will be garbage-collected if you leave without saving. [src1, src8]
# Normal state
cat .git/HEAD # → ref: refs/heads/main
git branch # → * main
# Detached HEAD state
cat .git/HEAD # → a1b2c3d4e5f6... (a commit SHA)
git status # → HEAD detached at a1b2c3d
git branch # → * (HEAD detached at a1b2c3d)
Verify: git status → expected: HEAD detached at <hash>
or On branch <name>
2. Recover: save new commits to a new branch
The most common recovery path — preserves all commits made while detached. [src1, src3, src7]
git status
git log --oneline -10 # see commits made in detached state
# Create a branch at current position — saves all detached commits
git switch -c my-feature-branch # Git 2.23+
# OR: git checkout -b my-feature-branch # older Git
git status # now shows “On branch my-feature-branch”
git push -u origin my-feature-branch
Verify: git status → expected: On branch my-feature-branch
3. Recover: cherry-pick commits to an existing branch
If you want to integrate specific detached-HEAD commits into an existing branch. [src1, src7]
git log --oneline # note the commit hash(es)
git switch main
git cherry-pick a1b2c3d # copy one commit
git cherry-pick a1b2c3d^..f4e5d6c # copy a range
Verify: git log --oneline -5 → expected: cherry-picked commits visible on
target branch
4. Discard: return to branch without saving
If you made no commits (or want to throw them away). [src1]
git switch main # clean return
git checkout -f main # force — discards uncommitted changes too
Verify: git status → expected: On branch main
5. Recover lost commits via reflog
If you already switched away and lost the commits — ~30-day recovery window. [src2, src7]
git reflog # find “commit: add important feature” entry
# Example output:
# a1b2c3d HEAD@{0}: checkout: moving from abc123 to main
# f4e5d6c HEAD@{1}: commit: add important feature ← lost commit
# ...
git branch recover-work f4e5d6c # create branch at lost commit
git switch recover-work # go there and verify
Verify: git branch -v → expected:
recover-work f4e5d6c add important feature
6. CI/CD: detached HEAD is expected
CI/CD pipelines check out a SHA for reproducibility — always creates detached HEAD. This is correct behavior. [src5]
# GitHub Actions: by default checks out detached HEAD (correct)
- uses: actions/checkout@v4
# To get a real branch reference for PRs:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
Git 2.49+ also introduced git clone --revision for minimal CI clones with intentional detached
HEAD. [src1]
# Minimal CI clone at specific tag — intentional detached HEAD (Git 2.49+)
git clone --revision refs/tags/v1.0 --depth=1 https://github.com/org/repo.git
Code Examples
Bash: safe detached HEAD detector and recovery helper
Full script: bash-safe-detached-head-detector-and-recovery-help.sh (61 lines)
#!/bin/bash
RECOVER_BRANCH="${1:-}"
echo "=== Git HEAD State Inspector ==="
HEAD_REF=$(cat .git/HEAD 2>/dev/null)
if echo "$HEAD_REF" | grep -q "^ref:"; then
echo "Normal state — on branch: $(git branch --show-current)"
exit 0
fi
DETACHED_SHA=$(git rev-parse --short HEAD 2>/dev/null)
echo "DETACHED HEAD at: $DETACHED_SHA"
LAST_BRANCH=$(git reflog | grep -E "checkout: moving from" | head -1 \
| sed 's/.*moving from \([^ ]*\).*/\1/')
echo " Last known branch: ${LAST_BRANCH:-unknown}"
if [ -n "$LAST_BRANCH" ]; then
COUNT=$(git log --oneline "${LAST_BRANCH}..HEAD" 2>/dev/null | wc -l | tr -d ' ')
echo " Commits made while detached: $COUNT"
[ "$COUNT" -gt 0 ] && git log --oneline "${LAST_BRANCH}..HEAD" | head -10 | sed 's/^/ /'
fi
if [ -n "$RECOVER_BRANCH" ]; then
git switch -c "$RECOVER_BRANCH" 2>/dev/null || git checkout -b "$RECOVER_BRANCH"
echo "Work saved to branch: $RECOVER_BRANCH"
else
echo ""
echo "Run: $0 <branch-name> to auto-save and recover"
echo "Or: git switch -c <new-branch>"
echo "Or: git switch ${LAST_BRANCH:-main} (discard detached commits)"
fi
Python: git HEAD state analyzer
Full script: python-git-repository-head-state-checker.py (105 lines)
#!/usr/bin/env python3
"""
Input: path to a Git repository (default: current dir)
Output: dict with HEAD state, branch, detached commit count, and recovery commands
"""
import subprocess, sys
from pathlib import Path
def run_git(args, cwd='.'):
r = subprocess.run(['git']+args, capture_output=True, text=True, cwd=cwd)
return r.stdout.strip(), r.returncode
def analyze_head(repo='.'):
_, rc = run_git(['rev-parse','--git-dir'], repo)
if rc != 0: return {'error': f'{repo} is not a git repo'}
head = (Path(repo)/'.git'/'HEAD').read_text().strip()
sha, _ = run_git(['rev-parse','--short','HEAD'], repo)
if head.startswith('ref: refs/heads/'):
branch = head.replace('ref: refs/heads/','')
return {'state':'normal','branch':branch,'sha':sha,
'message':f'On branch: {branch} ({sha})'}
# Detached — find last branch from reflog
reflog, _ = run_git(['reflog','--pretty=%gs'], repo)
last_branch = None
for line in reflog.splitlines():
if 'checkout: moving from' in line:
last_branch = line.split('moving from ')[1].split(' to ')[0]
break
commits_out, _ = run_git(['log','--oneline',f'{last_branch}..HEAD'], repo) \
if last_branch else ('', 0)
commits = [c for c in commits_out.splitlines() if c]
return {
'state': 'detached', 'sha': sha, 'last_branch': last_branch,
'detached_commits': commits,
'message': f'DETACHED HEAD at {sha}',
'recovery': {
'save_new_branch': 'git switch -c <branch-name>',
'discard_return': f'git switch {last_branch or "main"}',
'cherry_pick': f'git switch <target> && git cherry-pick {sha}',
}
}
info = analyze_head(sys.argv[1] if len(sys.argv)>1 else '.')
if 'error' in info: print(info['error']); sys.exit(1)
print(info['message'])
if info['state']=='detached':
print(f"Last branch: {info.get('last_branch','unknown')}")
print(f"Detached commits: {len(info['detached_commits'])}")
for c in info['detached_commits']: print(f" {c}")
print("\nRecovery options:")
for k,v in info['recovery'].items(): print(f" {k}: {v}")
Bash: reflog-based lost commit recovery
Full script: bash-reflog-based-lost-commit-recovery.sh (53 lines)
#!/bin/bash
KEYWORD="${1:-}"
echo "=== Lost Commit Recovery via reflog ==="
git reflog --pretty=format:"%C(yellow)%h%Creset %C(bold)%gd%Creset %C(dim)%ar%Creset %gs" \
--color=always | head -30
echo ""
echo "=== Find Detached HEAD commits ==="
git reflog | grep -i "detached\|commit:" | head -20
echo ""
echo "=== Recovery ==="
echo "1. Find the commit hash from reflog above"
echo "2. git branch recover-work <hash>"
echo "3. git switch recover-work"
echo "4. git log --oneline -5 (verify)"
echo ""
echo "Last resort (beyond reflog expiry):"
echo " git fsck --lost-found"
echo " ls .git/lost-found/commit/"
Anti-Patterns
Wrong: Committing new work in detached HEAD without creating a branch
# BAD — commits have no branch anchor [src1, src3, src5]
git checkout a1b2c3d # enters detached HEAD
git add . && git commit -m "important feature"
git checkout main # WARNING: "leaving N commits behind" — easy to dismiss!
# Commits become orphaned — GC'd in ~30 days
Correct: Create a branch before switching away
# GOOD — lock in your work before switching [src1, src3]
git checkout a1b2c3d
git add . && git commit -m "important feature"
git switch -c my-feature # save NOW, then switch is safe
git switch main # commits are safe on my-feature branch
Wrong: Using git checkout for branch navigation (ambiguous)
# BAD — git checkout is overloaded; easy accidental detach [src1, src3]
git checkout main # fine — goes to branch
git checkout a1b2c3d # detached HEAD! same command, different behavior
git checkout v1.2.3 # detached HEAD! (tag checkout)
# One typo or copy-paste of a hash → silent detach
Correct: Use git switch for branch navigation
# GOOD — git switch is branch-only; explicit about detaching [src1]
git switch main # goes to branch
git switch a1b2c3d # ERROR: "invalid reference" — clear failure
git switch --detach main # explicit intentional detach — intent is clear
git switch --detach v1.2.3 # inspect a tag explicitly
Wrong: Checking out origin/main directly
# BAD — origin/main is a remote-tracking ref, not a local branch [src1, src3, src6]
git checkout origin/main # detached HEAD at remote ref
# Any commits here are orphaned; you're not "on main"
Correct: Use git switch to create a tracking branch
# GOOD — creates a local tracking branch [src1, src3]
git switch main # uses existing local main (tracks origin/main)
git switch -c main origin/main # create local branch if it doesn't exist
git checkout --track origin/main # same, older syntax
Common Pitfalls
- Git's warning is easy to dismiss: When you switch away from detached HEAD with uncommitted commits, Git prints a warning containing the orphaned commit hashes. Many developers dismiss this warning. Write down the hashes immediately or create a branch right then. [src1, src3, src5]
- Garbage collection has a ~30-day window: Orphaned commits from detached HEAD are not
immediately deleted.
git reflogkeeps them accessible for 30 days (configurable viagc.reflogExpire). After that,git gcdeletes them permanently. [src2, src8] - Tags always cause detached HEAD: Checking out a tag (
git checkout v1.0) always enters detached HEAD because tags are immutable. This is expected but surprises newcomers. [src1, src6] - CI/CD pipelines are always in detached HEAD: GitHub Actions, Jenkins, and most CI systems check out a specific commit SHA for reproducibility. This is correct — don't try to “fix” it inside CI. [src5]
git checkoutis overloaded: The same command checks out branches, files, commits, and trees.git switch(Git 2.23+) is branch-only and gives clearer error messages for detached-HEAD scenarios. [src1, src3]- Detached HEAD with stashed changes: A
git stash popin detached HEAD creates a commit in the detached state. Create a branch before applying stash work if you intend to keep it. [src1] - Interactive rebase uses detached HEAD internally: During
git rebase -i, Git temporarily enters detached HEAD to replay commits. This is normal and resolves automatically. Do not be alarmed by this. [src1] - Git 2.49+
--revisionclone creates detached HEAD: The newgit clone --revisionflag (Git 2.49, March 2025) intentionally creates a detached HEAD without a tracking branch. This is by design for CI/CD use cases. [src1]
Diagnostic Commands
# Detect detached HEAD
git status # “HEAD detached at <hash>”
cat .git/HEAD # SHA = detached; “ref: refs/heads/...” = normal
git branch # “* (HEAD detached at ...)”
# Understand what happened
git log --oneline -10
git reflog # full HEAD movement history
git log --oneline --graph --all # all branches including detached
# Recovery
git switch -c <new-branch> # save + create branch (Git 2.23+)
git checkout -b <new-branch> # same, older Git
git switch <existing-branch> # discard and go home
git cherry-pick <hash> # copy specific commit to current branch
# Lost commit recovery
git reflog # find lost commit hash
git reflog --pretty=format:"%h %gs %ar" # friendlier format
git branch recover <lost-hash> # create branch at lost commit
git fsck --lost-found # last resort: find ALL unreachable objects
# Prevention
git switch --detach <hash> # explicit intentional detach
git switch <branch> # always use switch for branch navigation
git config --global advice.detachedHead true # ensure warnings are on
Version History & Compatibility
| Feature | Available Since | Notes |
|---|---|---|
git switch |
Git 2.23 (Aug 2019) | Preferred over git checkout for branch navigation [src1] |
git switch -c |
Git 2.23 | Create and switch (replaces git checkout -b) [src1] |
git switch --detach |
Git 2.23 | Explicit intentional detached HEAD [src1] |
git restore |
Git 2.23 | Replaces git checkout -- <file> [src1] |
git clone --revision |
Git 2.49 (Mar 2025) | Intentional detached HEAD clone for CI/CD [src1] |
| Improved detached HEAD messages | Git 2.49 (Mar 2025) | Clearer guidance when entering/leaving detached HEAD |
git reflog |
Git 1.5.0 | 30-day default window [src2] |
git fsck --lost-found |
Git 1.5.0 | Find all unreachable objects [src2] |
gc.reflogExpire |
Git 1.6.0 | Configure reflog retention period [src8] |
| Detached HEAD concept | Git 1.0 | Core Git concept; unchanged since inception [src8] |
| Latest stable | Git 2.53 (Feb 2026) | All detached HEAD features fully supported |
When to Use / When Not to Use
| Use Detached HEAD For | Don't Use Detached HEAD For | Use Instead |
|---|---|---|
Inspecting historical code: git switch --detach <hash> |
Starting new development work | git switch -c <feature-branch> |
| Building/testing a specific tagged release | Hotfixes — create a branch instead | git switch -c hotfix/<issue> |
| CI/CD pipelines checking out exact SHAs (expected) | Any work you intend to commit and keep | Any named branch |
Bisecting: git bisect manages detached HEAD for you |
Code review — use a named branch | git switch -c review/<pr> |
Minimal CI clones: git clone --revision (Git 2.49+) |
Long-running experiments | git switch -c experiment/<name> |
Important Caveats
git switchmay not be available on older systems: Git 2.23 was released August 2019, but some enterprise Linux distributions (RHEL 7, CentOS 7) ship with much older Git. On those systems, usegit checkout -binstead ofgit switch -c.git fsck --lost-foundcan recover beyond reflog window: If reflog has expired,git fsck --lost-foundwrites unreachable objects to.git/lost-found/. This is the last resort for very old orphaned commits. [src2]- Shallow clones limit reflog recovery: CI/CD systems that use
--depth=1shallow clones have very limited reflog history. Commits orphaned in a shallow clone may not be recoverable via reflog. [src5] - Interactive rebase uses detached HEAD internally: During
git rebase -i, Git temporarily enters detached HEAD to replay commits. This resolves automatically when the rebase completes or is aborted. [src1] ORIG_HEADpreserves pre-merge/rebase position: After a merge, rebase, or reset, Git stores the previousHEADinORIG_HEAD. Usegit reset --hard ORIG_HEADto undo a bad merge or rebase quickly. [src8]- Git 2.49+ improved detached HEAD messages: Starting with Git 2.49 (March 2025), Git provides clearer guidance messages when entering or leaving detached HEAD state, making recovery steps more obvious.