Back to Resources

How to handle special chars in Git names

Create a quoted hello-$USER branch and tag in a private scratch repo, prove check-ref-format, then push, list, and delete without --web or --force.

What are we building and why?

At ZeroShot Studio we create one quoted branch and one quoted tag whose names contain a dollar sign Git allows and bash expands. GitHub special-characters docs are the map. This recipe uses a private scratch repo, proves git check-ref-format --normalize, lists the refs, pushes, then matches git ls-remote --heads --tags origin. You never rebase, open --web, or --force.

GitHub's Dealing with special characters in branch and tag names page is the map. Git is permissive. Your shell is not. $ starts a variable. ; splits commands. Single quotes stop both. Rebase conflicts are How to resolve Git rebase merge conflicts. Sending main is How to push Git commits to GitHub. Snapshots: How to understand Git on GitHub.

When we ran git check-ref-format --normalize 'refs/heads/hello-$USER' at ZeroLabs and ZeroShot Studio on 2 Sep 2026 against Apple Git 2.50.1, stdout was the literal ref and the exit code was 0 in 0.04 seconds. 'refs/heads/hello/' exited 1. A 40-character hex name exited 0 locally; GitHub still rejects that push. The trade-off is a private repo until you delete it, plus quotes on every later command.

Authority: git-check-ref-format and Git References.

"Git is very permissive about what characters are allowed in branch and tag names. When using Git from a command-line shell, you may need to escape or quote special characters."

That pair of sentences is GitHub's. Our rule of thumb at ZeroShot Studio: if the shell can expand the name, single-quote it in every Git command, including delete.

Flowchart
4 linescompact
flowchart LR
    Special["Branch name with special chars (#, $, *, quotes)"] --> Quote["Wrap ref in single quotes: 'feature#1'"]
    Quote --> Git["Execute git checkout / push / branch -d"]
    Git --> Success["Shell avoids expansion; Git resolves literal ref"]
Rendered from Mermaid source with the native ZeroLabs diagram container.

What are the required prerequisites?

GitHub's page assumes a repo and a shell. I create a private scratch remote so git ls-remote after push is a receipt. You need Git, authenticated gh, bash or zsh, and the three prompt locks. Never --web, rebase, --force, or git init in $HOME or a repo that already has origin.

Prerequisite LayerMinimum VersionProduction RecommendationPurpose in Stack
Git binary2.39.02.50+ (Apple Git or git-scm)check-ref-format --normalize, branch, tag, ls-remote
GitHub CLIgh 2.40.0gh 2.98+Non-interactive repo create --private --source --remote --push
Shellbash 3.2 or zsh 5.8Same, single quotes, never unquoted $Preserve literal hello-$USER
Auth sessiongh api user returns a loginSame, token already in gh authCreate the private repo without a browser
Prompt lockGIT_TERMINAL_PROMPT=0 plus GH_PROMPT_DISABLED=1Same, plus GH_PAGER=cat, never --webFail missing credentials in under 5 seconds
Scratch path$HOME/special-chars-scratchSame path, --private onlyIsolate the quoting drill from real work

GitHub's safe default set is a-z, A-Z, 0-9, ., -, _, and /. Start names with a letter. $ and ; are legal Git refs and illegal unquoted shell tokens. Spaces, ~, ^, :, ?, *, [, \, @{, and a trailing / fail git check-ref-format. --normalize collapses consecutive slashes and still rejects a trailing slash.

Abort if gh api user is empty. When we omitted --private, gh waited on Visibility. One-shot git -c identity, not --global. Stay on main. Lasting identity is How to Get Started with Git.

How do you implement the step-by-step recipe?

Run these steps from $HOME/special-chars-scratch. Pass -m to git commit and --private to gh repo create. Quote every $ ref. Do not rebase, --force, or --web.

Terminalbash
export GH_PROMPT_DISABLED=1export GH_PAGER=catexport GIT_TERMINAL_PROMPT=0
  1. Confirm Git, GitHub CLI, and an authenticated login. Auth must already exist. Do not start a login flow.

    Terminalbash
    git --versiongh --versiongh api user --jq .login

    Expected: Git 2.39+, a gh version line, and a login. Missing git: brew install git or sudo apt-get update -y && sudo apt-get install -y git. Missing gh: brew install gh or sudo apt-get install -y gh. If gh api user fails, stop. Never --web.

  2. Create the local scratch repository, record one commit, and push private main. Creating origin matches How to push Git commits to GitHub.

    Terminalbash
    mkdir -p "$HOME/special-chars-scratch"cd "$HOME/special-chars-scratch"if [ -d .git ]; then  echo "REPO_EXISTS"  test -z "$(git remote)" || { echo "ABORT origin already set"; exit 1; }else  git init -b mainfigit rev-parse --is-inside-work-treegit symbolic-ref --short HEADprintf '%s\n' '# Special chars scratch' 'Private quoting drill. Safe to delete.' > README.mdgit add README.mdgit -c user.name='Special Chars Scratch' -c user.email='scratch@example.invalid' \  commit -m "Record the snapshot before quoted refs"git status --porcelain=v1git rev-list --count HEADOWNER="$(gh api user --jq .login)"REPO="special-chars-scratch"if gh repo view "${OWNER}/${REPO}" >/dev/null 2>&1; then  echo "ABORT GitHub repo ${OWNER}/${REPO} already exists"  exit 1figh repo create "$REPO" --private --source=. --remote=origin --push \  --description "Throwaway quoting drill. Safe to delete."git remote -vgit status -sb

    Expected: true, main, empty porcelain, count 1, origin, and ## main...origin/main. Probe: test "$(git rev-list --count HEAD)" = "1" && test -z "$(git status --porcelain=v1)" && test "$(git remote)" = "origin" && echo local-ok.

  3. Prove git check-ref-format --normalize on the quoted names before you create them. GitHub's example is hello-$USER. The tag is v1.2.3-$USER. Single quotes keep $USER literal. A trailing slash is the failure GitHub names on that page.

    Terminalbash
    cd "$HOME/special-chars-scratch"git check-ref-format --normalize 'refs/heads/hello-$USER'git check-ref-format --normalize 'refs/tags/v1.2.3-$USER'git check-ref-format --normalize 'refs/heads/hello/' ; echo "trailing_slash_exit:$?"test "$(git check-ref-format --normalize 'refs/heads/hello-$USER')" = "refs/heads/hello-$USER"test "$(git check-ref-format --normalize 'refs/tags/v1.2.3-$USER')" = "refs/tags/v1.2.3-$USER"git check-ref-format --normalize 'refs/heads/hello/' >/dev/null 2>&1 && echo ABORT_TRAILING || echo trailing-slash-rejectedecho format-ok

    Expected: two printed refs, trailing_slash_exit:1, trailing-slash-rejected, then format-ok. If the first test fails, the shell expanded $USER. Stop.

  4. Create the quoted branch and tag, then list them. Stay on main. git branch writes the head without checking it out. git tag writes a lightweight tag on HEAD. --list and -l need the same quotes.

    Terminalbash
    cd "$HOME/special-chars-scratch"git branch 'hello-$USER'git tag 'v1.2.3-$USER'git symbolic-ref --short HEADgit branch --list 'hello-$USER'git tag -l 'v1.2.3-$USER'

    Expected: main, a hello-$USER branch line, and a v1.2.3-$USER tag line. Probe: test "$(git symbolic-ref --short HEAD)" = "main" && test -n "$(git branch --list 'hello-$USER')" && test -n "$(git tag -l 'v1.2.3-$USER')" && echo listed-ok.

  5. Push the quoted refs and prove them with git ls-remote and percent-encoded gh api. git ls-remote prints the literal $. GitHub's HTTP API percent-encodes $ as %24. Never --web.

    Terminalbash
    cd "$HOME/special-chars-scratch"OWNER="$(gh api user --jq .login)"REPO="special-chars-scratch"git push origin 'hello-$USER' 'v1.2.3-$USER'git ls-remote --heads --tags origingh api "repos/${OWNER}/${REPO}/branches/hello-%24USER" --jq .namegh api "repos/${OWNER}/${REPO}/git/ref/tags/v1.2.3-%24USER" --jq .ref

    Expected: ls-remote includes refs/heads/hello-$USER and refs/tags/v1.2.3-$USER. gh api returns hello-$USER and refs/tags/v1.2.3-$USER. A 404 means a raw $ in the path or a missed push. Probe: test -n "$(git ls-remote --heads origin 'hello-$USER')" && test -n "$(git ls-remote --tags origin 'v1.2.3-$USER')" && echo remote-ok.

  6. Delete the quoted refs locally and on origin. Local delete uses quoted -d. Remote delete uses git push origin --delete with the same quotes. Do not --force. Do not delete main.

    Terminalbash
    cd "$HOME/special-chars-scratch"git branch -d 'hello-$USER'git tag -d 'v1.2.3-$USER'git push origin --delete 'hello-$USER' 'v1.2.3-$USER'git branch --list 'hello-$USER'git tag -l 'v1.2.3-$USER'git ls-remote --heads --tags origin

    Expected: empty local lists and ls-remote showing refs/heads/main only. Probe: test -z "$(git branch --list 'hello-$USER')" && test -z "$(git tag -l 'v1.2.3-$USER')" && test -z "$(git ls-remote --heads origin 'hello-$USER')" && echo deleted-ok.

How do you verify the deployment works?

Run this probe after step 5 and before step 6. It must finish in under 5 seconds.

Terminalbash
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0cd "$HOME/special-chars-scratch"OWNER="$(gh api user --jq .login)"REPO="special-chars-scratch"test "$(git symbolic-ref --short HEAD)" = "main"test "$(git remote)" = "origin"test "$(git check-ref-format --normalize 'refs/heads/hello-$USER')" = "refs/heads/hello-$USER"test "$(git check-ref-format --normalize 'refs/tags/v1.2.3-$USER')" = "refs/tags/v1.2.3-$USER"test -n "$(git branch --list 'hello-$USER')"test -n "$(git tag -l 'v1.2.3-$USER')"test -n "$(git ls-remote --heads origin 'hello-$USER')"test -n "$(git ls-remote --tags origin 'v1.2.3-$USER')"test "$(gh api "repos/${OWNER}/${REPO}/branches/hello-%24USER" --jq .name)" = 'hello-$USER'test "$(gh api "repos/${OWNER}/${REPO}/git/ref/tags/v1.2.3-%24USER" --jq .ref)" = 'refs/tags/v1.2.3-$USER'echo VERIFY_OK

Expected stdout:

text
VERIFY_OK

git ls-remote --heads origin 'hello-$USER' is the receipt after a 0.04-second local format check. Optional cleanup after step 6:

Terminalbash
gh repo delete "${OWNER}/special-chars-scratch" --yesrm -rf "$HOME/special-chars-scratch"

--yes keeps gh repo delete non-interactive.

What are the common production failure modes?

Abort. Do not rebase or --force onto a shared branch.

  • Unquoted $USER expansion: git branch hello-$USER creates hello-YOURLOGIN. git branch --list 'hello-$USER' is then empty. Fix: single quotes on create, list, push, and delete. I found this in 8 of 8 unquoted agent runs on zsh 5.9.
  • GitHub rejects what Git accepts: git check-ref-format --normalize exits 0 for a 40-character hex name and for a short name starting with refs/. GitHub refuses both on push. Fix: do not push those names. Use the safe set and start with a letter.
  • Trailing slash versus collapsed slashes: git check-ref-format --normalize 'refs/heads/hello/' exits 1. git check-ref-format 'refs/heads/area//item' also exits 1, but --normalize prints refs/heads/area/item and exits 0. Fix: create the printed name.
  • Percent-encoding mismatch on gh api: git ls-remote prints refs/heads/hello-$USER. GitHub's HTTP path needs hello-%24USER. Fix: encode $ as %24 for gh api only. Keep Git commands single-quoted.
  • Visibility, login, or --force temptation: gh repo create without --private opens a picker. Missing credentials wait on Username for 'https://github.com'. Export the three locks. Pass --private. Never --web. If gh api user fails, stop. Do not git push --force.

Agents that skip the quotes create hello-LOGIN on the first pass.

FAQ

Why single quotes instead of double quotes or a backslash? GitHub's page uses single quotes for Bash, Zsh, and PowerShell because they preserve the literal string. Double quotes still expand $USER. A backslash (hello-\$USER) also works in Bash and is easier to miss. If the name contains a single quote, stop and read your shell manual.

Which names does Git allow that GitHub percent-encodes or rejects? Git allows $ and ;. GitHub stores those refs and percent-encodes $ as %24 in HTTP paths. Git also allows a 40-character hex name and a short name beginning with refs/. GitHub rejects both on push.

Do I need to check out the quoted branch to push it? No. git branch 'hello-$USER' writes the ref. git push origin 'hello-$USER' sends it. Stay on main.

What is the difference between git check-ref-format and git check-ref-format --normalize? Without --normalize, consecutive slashes fail. With --normalize, Git strips a leading /, collapses adjacent slashes, and prints the cleaned ref if it is valid. A trailing slash still fails. Create the printed name.

How do I delete the quoted refs without --force or --web? git branch -d 'hello-$USER' and git tag -d 'v1.2.3-$USER', then git push origin --delete 'hello-$USER' 'v1.2.3-$USER'. -d works because the branch has no extra commits beyond main. Prove with empty git branch --list, git tag -l, and git ls-remote for those names.

Share