Version ~/.claude, but get the ignore file right first
Versioning ~/.claude in git backs up months of config tuning, but needs an exclusion list in danger order — credentials first, ~/.claude.json and prompt history second, runtime state third — and the popular projects/**/*.jsonl carve-out leaks superseded transcripts.
The official documentation describes the mechanism this relies on. The technique itself is community practice.
What this costs you if it is wrong
Get the ignore file wrong and you push a live OAuth token or a full conversation transcript to a remote — verify with a staged dry run before the first `git add`, never after, and treat a leaked `.credentials.json` as a credential to revoke via `/logout` rather than a commit to amend away. Auto memory and transcripts under `projects/` can contain client names, file contents, and command output from private work, so a private remote is the only sane default. Committing `settings.json` also publishes its `env` block verbatim, which a gitignore cannot protect you from.
You spent months tuning ~/.claude — a CLAUDE.md that finally says the right things, permission rules that stopped the prompt fatigue, half a dozen skills you would not want to rewrite. It lives on exactly one machine and nothing backs it up. The obvious move is git init in that directory. The obvious move is also, done carelessly, how you push a live OAuth token to a remote.
The community practice is to version the directory in place rather than symlink-farm it from a dotfiles repo. That is a reasonable default here, and the docs quietly support it: ~/.claude is a plain directory of authored files sitting next to a pile of runtime state, and Claude Code refuses to write a ~/.claude/workflows/ file only when the target file is a symlink, so a dotfiles-managed ~/.claude still works either way.
Start from the docs' own commit column
The file reference table in claude-directory already carries a Commit column. It is written with project .claude/ in mind, but the same file types exist at global scope and the reasoning transfers cleanly.
Path under ~/.claude/ | Verdict | Why |
|---|---|---|
CLAUDE.md, rules/*.md | Commit | Marked ✓ in the docs' commit column |
settings.json | Commit, after scrubbing | ✓ in the docs — but see the warning below about env |
skills/, commands/, agents/, output-styles/, workflows/ | Commit | All ✓; these are the months of tuning |
statusline.sh | Commit | Not in the table, but the docs' own examples put it at exactly this path |
keybindings.json, themes/ | Your call | Global-only, no ✓. Harmless to version; the blank column is "not applicable to a team repo", not "dangerous" |
.credentials.json, credentials.json | Never | Your login credentials, per the docs' own state-files list |
~/.claude.json and backups/ | Never | App state and OAuth; backups/ is nothing but old copies of it |
history.jsonl | Never | Every prompt you have ever typed, with timestamps and project paths |
projects/ | Carve-out — see below | Transcripts of everything, mixed with auto memory |
shell-snapshots/, file-history/, paste-cache/, session-env/, image-cache/, uploads/, debug/, cache/, tasks/, plans/ | Ignore | Pure churn, swept automatically |
sessions/, jobs/, daemon/, teams/ | Ignore | Live process state — mailboxes, background sessions, crash detection |
feedback-bundles/, usage-data/, stats-cache.json, remote-settings.json, policy-limits.json | Ignore | Redacted transcript archives, usage reports, org policy cache |
plugins/ | Ignore | Installed artifacts, restorable from their marketplaces |
agent-memory/ | Your call | Marked ✓ in the docs; it is subagent memory, so read it before you publish it |
Two corrections to the list as it usually circulates. statsig/, todos/ and logs/ show up in every version of this gitignore, but the docs now describe all three as legacy directories that current versions no longer write — harmless to keep, just dead weight. And telemetry/ is not a directory Claude Code documents at all; OpenTelemetry export goes over the wire, not to a folder here.
The projects/ carve-out is the one interesting decision, and the usual form of it leaks
The appealing idea: ignore projects/**/*.jsonl and projects/**/*.txt instead of the whole directory, so that per-project auto memory at projects/<project>/memory/ rides along while conversation transcripts do not.
Two problems with that exact pair of globs, both readable straight off the docs' retention table:
- A set-aside transcript is named
projects/<project>/<session>.jsonl.superseded-<timestamp>. That does not end in.jsonl, so the glob misses it and a full conversation transcript gets committed. (<session>.orphaned-<timestamp>-<suffix>.jsonldoes end in.jsonland is caught.) projects/<project>/<session>/subagents/holds subagent transcripts andprojects/<project>/<session>/tool-results/holds large tool outputs spilled to files. Neither has a documented extension you can rely on.
The safer shape is to ignore the directory and re-include only what you want, which fails closed instead of open:
projects/**
!projects/*/
!projects/*/memory/
!projects/*/memory/**Then, before your first commit, confirm what git actually sees rather than what you intended it to see:
git -C ~/.claude add -An . | grep -Ei 'credential|history\.jsonl|\.jsonl|superseded|claude\.json'Empty output is the pass. Non-empty output is the reason to fix the ignore file before anything is staged.
The second leak channel nobody's gitignore covers
settings.json carries the ✓, and people commit it without reading it. Its env block is plain text in the settings file and its values reach every subprocess Claude Code starts — which is exactly why an API key, an OTLP bearer token, or a proxy secret ends up in there. A gitignore cannot help you with a file you deliberately committed. Read the env block before the first push, and move anything secret behind apiKeyHelper or otelHeadersHelper, which invoke a command instead of storing the value.
What is unverified here
Nothing in the official documentation suggests putting ~/.claude under version control, and no page gives an exclusion list. Every ingredient is documented — which files are commit-worthy, where credentials live per platform, what each state directory holds, that auto memory is machine-local — but the assembly is a community convention, not a supported workflow. Treat the boundary that way: the classification of individual paths above is docs-backed and should hold; the practice around it is one person's setup that others copied.
What would settle it is not a discussion, it is a dry run on a copy. Duplicate the directory, drop in your ignore file, stage everything, and read the resulting index for anything that looks like a token or a transcript. If that comes back clean on your machine, on your platform, with your CLAUDE_CONFIG_DIR, the practice is safe for you — which is the only scope in which this claim can be true, because the credentials file's location genuinely varies by platform and Keychain state.
Default the remote to private regardless. A public dotfiles repo is a fine thing to want; this is a bad first candidate for one.
Checkpoint
- Why is
test -f ~/.claude/.credentials.jsonreturning nothing not evidence that the file will stay absent?Show answerAnswer
Because its absence depends on Keychain state, not on your setup being inherently safe. On macOS the login normally lives in the Keychain, but when the Keychain rejects the write — an SSH session where it is locked, for instance — Claude Code falls back to writing
~/.claude/.credentials.jsonat mode0600, and on Linux it is always that file. So the ignore rule has to exist before the file can, and one SSH session weeks later is enough to create it. If you setCLAUDE_CONFIG_DIR, the credentials file follows that directory instead, so version the directory the variable actually points at. projects/**/*.jsonlmisses at least one kind of full conversation transcript. Which filename, and why does the glob not match it?Show answerAnswer
projects/<project>/<session>.jsonl.superseded-<timestamp>, a set-aside transcript. The glob requires the name to end in.jsonl, and that one ends in the timestamp, so it slips past and a full conversation transcript gets committed — whereas<session>.orphaned-<timestamp>-<suffix>.jsonldoes end in.jsonland is caught. Subagent transcripts underprojects/<project>/<session>/subagents/and spilled tool output undertool-results/have no documented extension to match on either, which is why the safer shape ignoresprojects/**and re-includes only the memory tree —!projects/*/,!projects/*/memory/,!projects/*/memory/**— so it fails closed instead of open.- You committed
settings.jsonand your gitignore is perfect. What could still be leaked, and which two settings exist to prevent it?Show answerAnswer
The
envblock insettings.json. It is plain text in the file and its values reach every subprocess Claude Code starts, which is exactly why an API key, an OTLP bearer token, or a proxy secret ends up in there — and a gitignore cannot protect a file you deliberately committed. The two settings that exist to prevent it areapiKeyHelperandotelHeadersHelper, which invoke a command instead of storing the value. Read the block before the first push, and confirm what git actually sees with a staged dry run (git -C ~/.claude add -An .filtered for credential and transcript names) rather than trusting what you meant the ignore file to do.