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 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"]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 Layer | Minimum Version | Production Recommendation | Purpose in Stack |
|---|---|---|---|
| Git binary | 2.39.0 | 2.50+ (Apple Git or git-scm) | check-ref-format --normalize, branch, tag, ls-remote |
| GitHub CLI | gh 2.40.0 | gh 2.98+ | Non-interactive repo create --private --source --remote --push |
| Shell | bash 3.2 or zsh 5.8 | Same, single quotes, never unquoted $ | Preserve literal hello-$USER |
| Auth session | gh api user returns a login | Same, token already in gh auth | Create the private repo without a browser |
| Prompt lock | GIT_TERMINAL_PROMPT=0 plus GH_PROMPT_DISABLED=1 | Same, plus GH_PAGER=cat, never --web | Fail missing credentials in under 5 seconds |
| Scratch path | $HOME/special-chars-scratch | Same path, --private only | Isolate 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.
export GH_PROMPT_DISABLED=1export GH_PAGER=catexport GIT_TERMINAL_PROMPT=0-
Confirm Git, GitHub CLI, and an authenticated login. Auth must already exist. Do not start a login flow.
git --versiongh --versiongh api user --jq .loginExpected: Git 2.39+, a
gh versionline, and a login. Missing git:brew install gitorsudo apt-get update -y && sudo apt-get install -y git. Missing gh:brew install ghorsudo apt-get install -y gh. Ifgh api userfails, stop. Never--web. -
Create the local scratch repository, record one commit, and push private
main. Creatingoriginmatches How to push Git commits to GitHub.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 -sbExpected:
true,main, empty porcelain, count1,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. -
Prove
git check-ref-format --normalizeon the quoted names before you create them. GitHub's example ishello-$USER. The tag isv1.2.3-$USER. Single quotes keep$USERliteral. A trailing slash is the failure GitHub names on that page.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-okExpected: two printed refs,
trailing_slash_exit:1,trailing-slash-rejected, thenformat-ok. If the firsttestfails, the shell expanded$USER. Stop. -
Create the quoted branch and tag, then list them. Stay on
main.git branchwrites the head without checking it out.git tagwrites a lightweight tag onHEAD.--listand-lneed the same quotes.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, ahello-$USERbranch line, and av1.2.3-$USERtag 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. -
Push the quoted refs and prove them with
git ls-remoteand percent-encodedgh api.git ls-remoteprints the literal$. GitHub's HTTP API percent-encodes$as%24. Never--web.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 .refExpected:
ls-remoteincludesrefs/heads/hello-$USERandrefs/tags/v1.2.3-$USER.gh apireturnshello-$USERandrefs/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. -
Delete the quoted refs locally and on origin. Local delete uses quoted
-d. Remote delete usesgit push origin --deletewith the same quotes. Do not--force. Do not deletemain.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 originExpected: empty local lists and
ls-remoteshowingrefs/heads/mainonly. 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.
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_OKExpected stdout:
VERIFY_OKgit ls-remote --heads origin 'hello-$USER' is the receipt after a 0.04-second local format check. Optional cleanup after step 6:
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
$USERexpansion:git branch hello-$USERcreateshello-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 --normalizeexits 0 for a 40-character hex name and for a short name starting withrefs/. 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--normalizeprintsrefs/heads/area/itemand exits 0. Fix: create the printed name. - Percent-encoding mismatch on
gh api:git ls-remoteprintsrefs/heads/hello-$USER. GitHub's HTTP path needshello-%24USER. Fix: encode$as%24forgh apionly. Keep Git commands single-quoted. - Visibility, login, or
--forcetemptation:gh repo createwithout--privateopens a picker. Missing credentials wait onUsername for 'https://github.com'. Export the three locks. Pass--private. Never--web. Ifgh api userfails, stop. Do notgit 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.