CCLearn

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.

docs corroborate

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/VerdictWhy
CLAUDE.md, rules/*.mdCommitMarked ✓ in the docs' commit column
settings.jsonCommit, after scrubbing✓ in the docs — but see the warning below about env
skills/, commands/, agents/, output-styles/, workflows/CommitAll ✓; these are the months of tuning
statusline.shCommitNot in the table, but the docs' own examples put it at exactly this path
keybindings.json, themes/Your callGlobal-only, no ✓. Harmless to version; the blank column is "not applicable to a team repo", not "dangerous"
.credentials.json, credentials.jsonNeverYour login credentials, per the docs' own state-files list
~/.claude.json and backups/NeverApp state and OAuth; backups/ is nothing but old copies of it
history.jsonlNeverEvery prompt you have ever typed, with timestamps and project paths
projects/Carve-out — see belowTranscripts of everything, mixed with auto memory
shell-snapshots/, file-history/, paste-cache/, session-env/, image-cache/, uploads/, debug/, cache/, tasks/, plans/IgnorePure churn, swept automatically
sessions/, jobs/, daemon/, teams/IgnoreLive process state — mailboxes, background sessions, crash detection
feedback-bundles/, usage-data/, stats-cache.json, remote-settings.json, policy-limits.jsonIgnoreRedacted transcript archives, usage reports, org policy cache
plugins/IgnoreInstalled artifacts, restorable from their marketplaces
agent-memory/Your callMarked ✓ 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>.jsonl does end in .jsonl and is caught.)
  • projects/<project>/<session>/subagents/ holds subagent transcripts and projects/<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:

text
projects/**
!projects/*/
!projects/*/memory/
!projects/*/memory/**

Then, before your first commit, confirm what git actually sees rather than what you intended it to see:

bash
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.json returning nothing not evidence that the file will stay absent?
    Show answer

    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.json at mode 0600, 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 set CLAUDE_CONFIG_DIR, the credentials file follows that directory instead, so version the directory the variable actually points at.

  • projects/**/*.jsonl misses at least one kind of full conversation transcript. Which filename, and why does the glob not match it?
    Show answer

    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>.jsonl does end in .jsonl and is caught. Subagent transcripts under projects/<project>/<session>/subagents/ and spilled tool output under tool-results/ have no documented extension to match on either, which is why the safer shape ignores projects/** and re-includes only the memory tree — !projects/*/, !projects/*/memory/, !projects/*/memory/** — so it fails closed instead of open.

  • You committed settings.json and your gitignore is perfect. What could still be leaked, and which two settings exist to prevent it?
    Show answer

    The env block in settings.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 are apiKeyHelper and otelHeadersHelper, 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.

Where this came from

Official pages that back this