# Repository Guidelines
`git-xcrypt` is a Rust CLI that transparently encrypts selected files on commit and decrypts them on checkout. The crate is split into `src/lib.rs` (logic) and a thin `src/main.rs` (arguments, exit codes). S-01 through S-08 have shipped, and `v0.1.0` was tagged 2026-08-07: `init` sets the repository up, `process` serves git's long-running filter protocol, `sync` regenerates the per-pattern `.gitattributes` lines, `export-key` and `unlock` carry a key to another machine and open a clone with it — `import-key` existed until 2026-08-06 and was **removed**, because it differed from `unlock` by exactly one step (not decrypting the working tree) and that step is now `unlock --key-only`; `lock` closes a repository and deletes its key, `diff` is the `textconv` driver that makes `git diff` compare plaintext, and `status` reports whether the declarations are actually enforced — scanning all reachable history, resolving git's `filter` **and `text`/`eol`** attributes for every declared path the way `git check-attr` does, repairing what it can with `--fix`, and exiting `2` on a configuration gap, `5` on a finding, `6` when it could not tell — in that order of precedence — so it works as a CI gate. That completes the v0.1 command set. The decisions live under `context/foundation/`.
## Hard rules
- **Never write to `stdout` on the clean/smudge filter path.** The filter's stdout *is* the file content; a stray `println!` corrupts it. Diagnostics to `stderr`.
- **Never commit a key or a secret.** Not to the working tree, a commit, or `stdout` outside an explicit `export-key`. Tests and examples included. `diff` is the one command that prints file content, so it refuses a key file **by its content** (`keyfile::holds_a_key`) — a location check was measured leaking the key when run from outside the repository, and it can say nothing about an exported copy.
- **Encryption must be deterministic.** Same plaintext and key, same ciphertext, or git reports unchanged files as modified.
- **Pass-through must be byte-identical.** `.gitattributes` carries a static `* filter=git-xcrypt`, so the filter runs on *every* file in the repository and passes unencrypted ones through untouched. A bug there corrupts the whole project, not just the secrets. `passthrough(x) == x` is a property test, not a nicety. The filter is registered as `filter.git-xcrypt.process` (long-running): a process per file was measured 22× slower.
- **Zero `unsafe`** — enforced by `unsafe_code = "forbid"` in `Cargo.toml`, not by convention. **Crypto from RustCrypto crates only — never hand-rolled, and never a construction we assemble ourselves.** Not "audited crates": the chosen `aes-siv` has no audit, and that is a recorded, deliberate risk. The cipher is AES-256-SIV (RFC 5297); the file format is frozen in `context/foundation/zalozenia.md`. **Since 2026-08-11 the crate is a pre-release, `0.8.0-rc.3`, and that is deliberate too** — it pulls `aes` 0.9, which picks the aarch64 hardware backend at runtime instead of behind a cfg, so `cargo install` from a registry stops being slower than a build from a clone. The risk is narrower than "rc" suggests: it is the only pre-release package in the graph, everything under it resolves to stable at MSRV 1.85, and the frozen vectors reproduce byte for byte on both versions.
- **An error aborts the operation — but only with `filter.git-xcrypt.required = true`.** Without that flag git ignores a non-zero filter exit: `git add` returns 0 and the plaintext reaches the object database. `init` must set it; two tests in `tests/filter_edge_cases.rs` guard it. Never pass content through silently.
- **A path is bytes, never a `String`.** The filter protocol carries `pathname=` as arbitrary bytes and only the terminating `\n` may be stripped. Lossy UTF-8 decoding, or `trim_end()` on a name that legally ends in a space, matches a file under a name it does not have — and in the pass-through direction that is a secret stored in the clear. Both were real bugs, both are regression-tested.
- **`lock` deletes the only copy of the key, so everything it cannot verify, it refuses over.** The opposite lean from `unlock`, which skips what it cannot read and says so: there a skip leaves a file encrypted, here it would leave a plaintext secret behind the command that promised to remove it. Both review passes found paths that ended with the key gone and a live checkout still in the clear — a linked worktree, a file that appeared while the prompt waited. When adding a case, ask what happens if the answer is wrong, and refuse in that direction. The newest one, 2026-08-11, shows both halves at once: residue whose temporary name hit the 255-byte ceiling no longer identifies its target, so `lock` refuses over it — measured, it used to warn, delete the key and exit `0` over a plaintext secret — while an ordinary temp-shaped file with an undeclared target still only gets a note, because a refusal there would be an outage over somebody else's file. Narrowness is the constraint on this rule, not an exception to it: a refusal from `lock` means the repository cannot be closed until the user finds what provoked it.
- **"I could not tell" must never be reported as "nothing is wrong."** `status` is read as a clean bill of health, and both of its review passes found states where it gave one it had not earned: an unreadable `packed-refs` yielded no tips, so the walk visited nothing, found nothing and exited `0` over a plaintext blob in history. Anything the scan could not cover — unresolvable references, unreadable objects, an unparsable index, a shallow clone's graft point — belongs in `undetermined`, which fails the gate — with its own code, `6`, since 2026-08-04. Exit `5` means "this repository has a problem" and nothing else; `6` means "I could not check"; a tool that broke has its own codes. A finding always outranks an unanswered question, never the other way round. **Above both sits configuration, since 2026-08-05:** a repository where git is not set up to enforce the declarations — no registered filter, no catch-all line, no `.git-xcrypt` — exits `2`, the frozen table's "configuration or a state conflict", because without a working configuration the data in the repository is worth nothing, and `5` was telling a repository that had never run `init` to go and rotate a secret it had never exposed. So the order is `2` > `5` > `6` > `0`. What it must never do is buy that clarity with silence: **every verdict prints every section**, so a misconfigured repository that also leaked still names the leak, the paths and the rotate-first procedure, and anything a run skipped on the way belongs in `undetermined` and is said out loud.
- **Pattern matching folds ASCII case, unconditionally — and the rendered line must fold with it.** Settled 2026-08-05 (open decision 13, owner's call: the safest option). Measured: `.git-xcrypt` declares `secrets/`, the user creates `Secrets/db.env`, and on APFS and NTFS those are *one* directory, so the mistake is invisible in the working tree — the filter passed the file through and `AWS_SECRET=hunter2` reached the object database with `git add` exiting 0. `src/rules/declaration.rs` now matches with `Case::Fold` and reads **no** configuration to decide it: `core.ignorecase` is not versioned, and consulting it on the clean path would make the same repository encrypt a different set of files on macOS than on Linux — the argument that keeps `core.autocrlf` off that path, unchanged. Because selection folds, `src/git/attributes.rs::fold_case` emits every ASCII letter as `[xX]`; a plain `**/secrets/**` answers `unspecified` for `SEcrets/db.txt` wherever `core.ignorecase` is false, which is *narrower* than the filter and so the direction that costs a file. `AttributeResolver` is the one place that still reads `core.ignorecase` and must keep doing so — it reproduces git rather than deciding anything, and confusing the two axes breaks the gap detection it exists for. Folding stops at ASCII, exactly where git's does, and it cannot be pushed further: `.gitattributes` matches bytes, so `[łŁ]` is a set of four bytes and matches no spelling at all.
- **Git decides whether to call the filter at all.** Adding a pattern to `.git-xcrypt` reaches the filter immediately, and does **not** reach a file git considers unchanged: the cached `stat` makes `git add -A` skip it, so an already-committed secret is committed again in the clear, exit `0`, no warning. Measured on git 2.55 past the racy-clean window. That gap is the reason `status --fix` patches the index rather than printing advice, and why `status` cannot be replaced by anything that only looks at the working tree.
- **The clean path never reads git's EOL config; the smudge path does.** Encrypted paths carry `-text`, so git-xcrypt owns the LF/CRLF conversion. Normalizing to LF before encryption must be identical on every machine, or the same file yields different ciphertext on Windows and Linux.
- **The smudge path converts only when the configuration asks — since 2026-08-11, and this one looks like a bug if you do not know why.** With `core.autocrlf` false or unset and `core.eol` unset — git's own default, and the one configuration in which git converts *nothing* — `eol::resolve_output` writes the stored bytes back unchanged instead of the platform's ending. Before that it fell through to `EolMode::Native`, so declaring a path secret changed its line endings, in opposite directions on the two platforms, with `git status` clean throughout: measured, a `CRLF` file came back `LF` on Linux and an `LF` file came back `CRLF` on Windows, while the identical undeclared file beside it was untouched. An explicit `core.eol=native`, or `eol=native` on the pattern, still selects the platform, because that is a user asking. **`eol::git_writes_crlf` does not inherit this and must not:** it answers a different question — "did git just expand `LF` in the bytes it handed us" — about a path some foreign line declared `text`, where git's unset default really is `native`. Answering it with our narrower rule would say "git left it alone" about a checkout that had just eaten the `CR` bytes out of a ciphertext. The two tables are computed separately in `src/rules/eol.rs` and one test pins them apart in both directions. The check-in half is *not* fixed and is a recorded limit: a file brought in with `CRLF` still comes back `LF`, because `clean` normalises before the header can record which ending was there.
- **The rendered `.gitattributes` lines must cover exactly the paths the filter encrypts — neither narrower nor broader.** They are called cosmetic because letting them go stale never stores a secret in the clear, and that is *all* the word means here — **and since 2026-08-06 a stale section fails the gate anyway**, exit `2` from both `sync --check` and `status`, because a section that does not cover every declared path is a declaration that is not being enforced (open decision 11; before that the two commands answered `1` and `0` on the same state). `-text` is what keeps git's own CRLF conversion off the ciphertext. Measured on git 2.55: an encrypted path without it, with any other attribute source declaring it `text`, had 34 `CR` bytes eaten out of a 2 MB blob — `git add` exited 0, the commit succeeded, and the file was unrecoverable at checkout. Narrower corrupts ciphertext; broader puts `-text` on files stored in the clear. The two files spell patterns differently (`secrets/` becomes `**/[sS][eE][cC][rR][eE][tT][sS]/**`, `*.env` needs a second line), so the rendering is the risky part, not the parsing — with one exception they now share: a name containing whitespace is closed with **quotes** on both sides (`"my secrets/"`, negation `!"my secrets/README.md"`), and since 2026-08-05 the backslash is only a glob escape, never a way to escape a space. A pattern that loses its quotes matches nothing and the path stops being encrypted in silence, so `src/rules/declaration.rs` refuses the two shapes an old file takes — a pattern ending in `\`, and a quoted pattern whose trailing words are attributes — and names the change in the message. Rendering them correctly is not enough on its own — a line that outranks them puts the conversion back, so `status` resolves `text`/`eol` too and fails the gate over it. Two shapes are dangerous and only two: `text` resolving to `set`, and `text` `unspecified` with a bare `eol=lf|crlf` (git's `convert_attrs` promotes that straight past binary detection). `-text`, `binary`, `text=auto` and any `core.autocrlf` are measured inert, and a gate that fires on those is worse than no gate.
- **On that shape the filter now refuses instead of passing the content through — since 2026-08-05.** Reporting it afterwards was never enough: `status` resolves only paths the index already knows, so on a *new* file it exits `0` and the first sign of trouble is the checkout that leaves no file. Git's own order makes the refusal possible — `clean` runs *before* git converts, so at the instant the filter answers nothing is damaged yet, and a `status=error` costs a refused `git add` rather than a blob nobody can ever decrypt. So `src/commands/filter.rs` resolves git's attribute stack (`gitattributes::AttributeResolver`, the same one `status` uses) for every path it is about to encrypt, and errors with the file and line number of the winning line. Three things to keep: the predicate is exactly the two shapes above and no wider, because with `required = true` a false refusal blocks *every* git operation in the repository; the question is asked only about paths that genuinely become ciphertext, because a file stored in the clear is git's to convert; and the resolver is built lazily, once per process, like `Context::head` — measured `git add -A` on 2000 files, 135 ms → 137 ms. It belongs in `filter.rs`, never in `decide::clean`, which stays a pure function of content because `lock` depends on it producing exactly the bytes git stores.
- **The same line arriving *after* the commit is a message problem, not a verdict problem — since 2026-08-05.** Git converts on the way out as well (blob, then git's conversion, then `smudge`), so the tag is handed bytes that were never stored and fails — correctly, and it must keep failing: the content really is not what was encrypted. What was wrong was saying `the file has been altered` over a blob that is intact to the byte, at the moment a user is least able to check. `src/commands/filter.rs` re-explains **only** an `Error::Crypto` from `smudge`, and only when three things agree: the bytes wear the fingerprint of git's expansion (no lone `LF`, at least one `CRLF`), git's attribute stack really converts that path, and the check-out direction on this machine is `CRLF` — which is *not* the check-in table, because `text eol=lf` converts going in and writes the stored bytes out untouched. Ask all of that **after** the tag has already failed and never before: `smudge` runs for every file of every checkout and every clone, and the happy path may not pay a byte for it (measured, 2000 files, 178 ms either way).
- **Normalisation is lossy, and the warning about it must stay narrower than git's — since 2026-08-06.** Two shapes cannot be restored from what `clean` stores: mixed `CRLF` and lone `LF` (under the default `text=auto` as much as under an explicit `text`), and a `CR` immediately before a `CRLF` (only under an explicit `text`, since `looks_binary` declines the lone `CR` otherwise). The first is the dangerous one to reason about, because `git status` stays **clean** — the changed bytes normalise to the plaintext already stored, so nothing signals it. `rules::eol::normalisation_is_reversible` answers both, and the rule to keep is *what it asks*: whether the original is recoverable, **not** whether the bytes change. Git's `core.safecrlf` asks the wider question and therefore warns about every LF-only file when `core.autocrlf=true` — measured on 2.55 — which it can afford because the setting defaults to off. Ours is always on and has no switch, so widening it to match git turns every Windows checkout into noise and the two lines that matter stop being read. Being a question about information, it needs no `EolMode` and reads no configuration, so it answers the same on every machine; keep it that way. It lives in `filter.rs` next to the refusal above, never in `decide::clean`, and it is **a warning, never a non-zero exit** — git's own `safecrlf=true` refuses with `rc=128`, and under `required = true` that would abort every git operation over content that is converted, not lost.
Why each rule, plus the file format and threat model: @context/foundation/zalozenia.md
## Language
English for code, comments, identifiers, commit messages, PR descriptions, and for headings, task titles, and field labels in `context/`. Polish for the prose under those headings and for conversation with AI agents.
## Structure
- `src/` — a `lib` for the logic plus a thin `bin` for arguments and exit codes. Only `main.rs` and `lib.rs` sit at the top; `lib.rs` stays because cargo infers the library from it, and moving it would need a `[lib] path` override that no tool expects. Everything else is grouped by what it answers to:
- `src/crypto/` — `cipher`, `format`, `key`, `keyfile`. **Frozen together:** a change here rewrites bytes already in someone's history.
- `src/git/` — `attributes`, `config`, `history`, `index`, `pktline`, `repo`. Each reproduces one thing git does and is answerable to a measurement against real git, never to a reading of its source.
- `src/rules/` — `declaration` (parses `.git-xcrypt`), `decide` (encrypt or pass through), `eol`. `decide` must stay a pure function of content and declaration.
- `src/commands/` — one module per verb, plus `filter`, the long-running filter engine that `process` is the entry point to.
- `src/util/` — `atomic` (write a file in one step or not at all) and `exit` (the frozen codes).
- `git::config` is imported as `gitconfig` everywhere, because `config` in this crate already means the user's declaration.
- `context/foundation/` — @context/foundation/prd.md (requirements, guardrails, open questions), @context/foundation/roadmap.md (what to build next, in dependency order), @context/foundation/zalozenia.md, @context/foundation/tech-stack.md
- `context/changes/<id>/` — per-change plan, research, review. Never in `foundation/`.
Read the PRD's `## Open Questions` first; none of them blocks today. Pick work from the roadmap's `## Backlog Handoff`. **v0.1 is out — `S-07` shipped 2026-08-07 as tag `v0.1.0`, and the backlog is empty.** `S-08` closed before it, 2026-08-04, in the order it had to: the binary-detection parity fix (a trailing `SUB`, 0x1A) had to land *before* anything ships, because `looks_binary` is frozen with the format from that date and changing it afterwards rewrites the ciphertext of existing files. What is left is outside v0.1 and none of it is scheduled: `S-09` (per-user keys, blocked on where a user identity lives), publishing to crates.io, a Homebrew tap, and the items under `## Parked`. Several questions were **closed as rejected** in August 2026 rather than left open — reproducible builds, the residue registry — so a "still open" reading of them is out of date; each says so at its own entry.
When asked "co dalej?" (what's next), answer with a lettered list — `a.`, `b.`, `c.`, … — one option per item, so the user can pick by letter.
## Conventions
Clippy runs with `-D warnings`. Errors via `thiserror`; no `unwrap()` on user-input paths. MSRV is **1.88**, declared in `Cargo.toml` and held by the `msrv` job in CI — measured, not assumed: edition 2024 alone would allow 1.85, but `let` chains in `if` conditions do not compile there.
## Testing
Tests must drive real git repositories in a temp dir — only git's stored objects prove these rules hold. `tests/harness/mod.rs` does that: it stands up a repo, registers the binary as a filter and returns raw blob bytes; `BareRemote` in the same file stands up a bare repository to push to, because "the blobs in the remote are encrypted" is a claim about a remote. Integration test files pull it in with `mod harness;`. Format vectors stay frozen once shipped.
`tests/acceptance.rs` holds the founding document's six-step scenario as one test. It is the one place where a regression in any part of the promise shows up as a single red line; keep it that way rather than splitting it up.
**The suite is scenarios, not unit tests.** It was cut from 466 tests to 89 on 2026-08-05, because a suite that mostly exercised functions in isolation was proving the parts and not the product. (That 89 is the count on that date, not today's — run the suite for the current number; it has grown with the changes since.) What is left is a frozen core — format vectors, the `proptest` properties, and the handful of guards a scenario cannot reach on every platform — plus one scenario per way the tool is actually used: `acceptance.rs` (a secret's life against a remote), `second_machine.rs`, `line_endings.rs` (the whole EOL table as one matrix, plus the content whose original normalisation throws away), `attributes.rs`, `lock_unlock.rs`, `exposure.rs`, `key_safety.rs`, `odd_repositories.rs`. Add to a scenario before adding a test file; a new file needs a way of *using* the tool that none of these covers.
**A deletion needs the same proof as a guard.** Before removing a test, mutate the rule it watches and confirm the scenario meant to inherit it goes red — a green suite after the removal proves nothing, because the removal is exactly what made it green. The 2026-08-05 reduction ran that cycle for every group and found 14 rules whose scenario did not catch the mutation; those guards stayed. Two rules turned out to have had **no** integration coverage at all: a pattern reaching `.gitattributes`, and a plaintext blob on a branch nobody had checked out — `status` gave that one a clean bill of health.
The three properties `zalozenia.md` asks for — `passthrough(x) == x`, `decrypt(encrypt(x)) == x`, `encrypt(x) == encrypt(x)` — are `proptest` properties in `src/rules/decide.rs` and `src/crypto/cipher.rs`, with the hand-written sample lists kept beside them: a generator that happens not to draw the empty file or a lone `CR` would quietly stop covering the shapes that once broke.
**The performance budgets are a test, and it is `#[ignore]`d.** `tests/performance.rs` holds the four numbers from PRD §Non-Functional Requirements — 25 µs per passed-through file, 30 µs per encrypted file, 2 ns per byte (all settled 2026-08-06), and 10 ms to build the attribute stack on a tree whose bulk is off the resolved path's ancestor chain (added 2026-08-07, when `AttributeResolver` stopped walking the whole working tree). Run it **before and after** any change to `src/commands/filter.rs`, `src/rules/decide.rs`, `src/rules/eol.rs`, `src/crypto/` or `src/git/attributes.rs`: `cargo test --release --test performance -- --ignored --nocapture`. **The 2 ns/B figure is calibrated on `aarch64-apple-darwin` and does not hold everywhere** — on a Windows x86-64 development machine the same 8 MB reads ~3.1 ns/B with `aes::hardware_accelerated()` answering `true`, because process spawn and 8 MB down a pipe are a large share of it there. Off that platform, compare a run against the previous run on the *same* machine rather than against the number. It prints what it measured, so a passing run still tells you which way the number moved. Not in CI for two measured reasons: the numbers only mean anything in `--release`, and the spread on a quiet development machine reached 11% of the baseline, which on a shared runner is a gate people learn to ignore. Three traps are recorded in that file rather than left to be rediscovered — a baseline of compressible content measures git's zlib rather than our cipher (91% of it, measured), `git add` cannot see the per-byte cost at all, so that budget goes through `diff`, and the attribute-stack tree costs seconds to create, so only the resolver's work sits inside the timed window.
**When adding a guard, try to break it.** Several rules above were once "guarded" by tests that passed with the rule removed — `required = true` by tests that set the flag themselves, the 128:1 binary ratio by nothing at all, the long-running protocol by a test that only counted encrypted files. Mutate the line, watch the suite go red, then put it back.
## Commits and PRs
**Never create a git branch.** Commit on whatever branch is checked out, including `master`. Switching or branching is the user's call, not yours — this overrides any default that says to branch off the main branch first.
CI lives in `.github/workflows/ci.yml`: `cargo test --all-targets` on Linux, macOS and Windows, plus `fmt --check`, `clippy --all-targets -- -D warnings`, `cargo audit`, `cargo deny check` (policy in `deny.toml`) and an MSRV build. `release.yml` builds five targets on a `v*` tag.
The three-platform matrix is not ceremony. Whole branches have never run on the development machine: `EolMode::Native`'s CRLF arm is `cfg!(windows)`, every key-permission test is `#[cfg(unix)]`, and APFS refuses the non-UTF-8 path names `decide` and the index are written to handle. Before adding a `#[cfg(unix)]` test, ask whether the other platform is now uncovered.