git-xcrypt 0.1.2

Transparent, deterministic encryption of selected files in a git repository: plaintext in your working tree, ciphertext in the remote.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
# git-xcrypt


Transparent encryption of selected files in a git repository: plaintext in your
working tree, ciphertext in the remote. A self-contained Rust binary — no
system `gpg`, no helper scripts, no external processes on the filter path.

**Status: v0.1.2.** Every command listed below works and is
covered by tests that drive a real git, on Linux, macOS and Windows.
[`CHANGELOG.md`](CHANGELOG.md) lists what this release contains, what is frozen
with it, and what it deliberately leaves out.

## Installing


Three routes to the same binary. Pick (a) if you do not have Rust, (c) if you do
and would rather not wait for a build.

### a) A ready-made binary from GitHub


No toolchain, nothing to compile. Download the archive for your platform from
the [releases page](https://github.com/rkarpin1/git-xcrypt/releases), unpack it
and put the binary in a directory on `PATH`. Five targets are published — Linux
x86-64 and ARM64, macOS Intel and Apple silicon, Windows x86-64 — each with a
`.sha256` file beside it and a build provenance attestation; see §Verifying a
downloaded release.

### b) `cargo install` — builds from source


```sh
cargo install git-xcrypt --locked
```

Needs Rust 1.88 or newer; `--locked` builds against the dependency versions this
release was tested with. This is the route with a chain you control end to end,
and the one to use if you want to audit what you run.

### c) `cargo binstall` — the same archive, without compiling


`cargo binstall` is a separate tool and is **not** part of cargo, so install it
first — once per machine:

```sh
cargo install cargo-binstall
```

Its own project also publishes ready-made binaries and an install script, if you
would rather not build that one either. Then:

```sh
cargo binstall git-xcrypt
```

It reads the version from crates.io and fetches the matching archive from the
releases page instead of building — measured with cargo-binstall 1.21.1 on
`x86_64-pc-windows-msvc`, which finds the artifact with no configuration on
either side. If no archive matches your target, it falls back to compiling, so
the outcome is the same as (b) rather than a failure.

### Checking it worked


```sh
git-xcrypt --version
git xcrypt --version      # git finds it on PATH as a subcommand too
```

## Quick start


```sh
git init my-project && cd my-project
git-xcrypt init                 # generates a key, registers the filter
```

Declare what is secret in `.git-xcrypt`, which uses `.gitignore` syntax and is
versioned with the project:

```gitignore
secrets/
*.env
!secrets/README.md              # an exception, stored in the clear
secrets/deploy.ps1  text eol=crlf
secrets/key.p12     binary
```

Then commit as usual:

```sh
git add -A && git commit -m "add secrets"
```

That is the whole setup — **`sync` is not part of it.** Your working tree still
shows plaintext; the repository stores ciphertext. Adding a pattern takes effect
on the very next `git add`, because the filter reads `.git-xcrypt` itself rather
than waiting for a command to translate it.

## Everyday work


Everything below is measured against a real git, not sketched.

### Adding a secret to a project that already has some


Nothing special: write the file, commit it. If it matches a pattern already in
`.git-xcrypt`, it is encrypted on the way in.

```sh
echo 'STRIPE_KEY=sk_live_...' > secrets/payments.env
git add -A && git commit -m "payment credentials"

git cat-file blob HEAD:secrets/payments.env | head -c 11 | xxd
# 00000000: 0047 4954 5843 5259 5054 00   .GITXCRYPT.
```

### Reading what actually changed in a secret


The blob is binary, so `git diff` would normally show nothing useful. `init`
registers a `textconv` driver that decrypts for comparison only:

```sh
git log -p -1 -- secrets/prod.env
```

```diff
@@ -1,2 +1,2 @@
-DB_PASS=hunter2
+DB_PASS=swordfish
 API_KEY=abc
```

Nothing decrypted is written anywhere: `init` also sets
`diff.git-xcrypt.cachetextconv = false`, because with caching on git stores the
**decrypted** text as blobs under `refs/notes/textconv/` — inside `.git/`, where
they would outlive `git-xcrypt lock`.

### What someone without the key sees


```sh
git clone <url> && cd my-project
head -c 24 secrets/prod.env | xxd
# 00000000: 0047 4954 5843 5259 5054 0001 0101 9077  .GITXCRYPT......

git-xcrypt status; echo $?
# 2   — this clone has no filter registered, so it is not safe to commit from

```

Exit `2` is the point: a clone inherits `.gitattributes` through history but not
`.git/config`, so git has no filter here until `unlock` runs. Committing a
declared file from such a clone would store it in the clear.

### The mistake that actually happens: declaring a pattern too late


A secret was committed before anyone thought to declare it. The filter notices
the moment it first encrypts that path:

```sh
echo '*.env' > .git-xcrypt
git add -A && git commit -m "declare secrets"
```

```
git-xcrypt: config/prod.env: this is the first time it is being encrypted, and
HEAD already holds it in the clear. The plain text stays in history; run
`git-xcrypt status` to see what is exposed, and rotate the secret if it was ever
pushed.
```

```sh
git-xcrypt status; echo $?
# 5   VERDICT: 1 path(s) leaked in history.

```

**The commit above already fixed the future** — from now on that path is stored
encrypted. What `status` keeps reporting is the past: the old plaintext blob is
still reachable, and still on the hosting service if it was ever pushed. It goes
on exiting `5` until that blob is gone, which is correct and deliberate.

`git-xcrypt status --fix` re-stages any declared file the index still holds in
the clear, which is the same repair for the case where you have not committed
yet. Neither it nor anything else in this tool rewrites history — the report
prints the `git-filter-repo` command for that, and the checklist starts with
rotating the secret, because rewriting history does not un-leak anything already
pushed, forked or cached.

### Locking the repository before handing the machine over


```sh
git-xcrypt export-key ~/backup/my-project.key   # first, and only once
git-xcrypt lock --yes
```

```sh
head -c 11 secrets/prod.env | xxd -p
# 0047495458435259505400        the working tree is ciphertext now
git status --porcelain          # empty: the bytes match what was committed
```

The key is gone from `.git/`. `unlock` with the copy brings everything back:

```sh
git-xcrypt unlock ~/backup/my-project.key
head -1 secrets/prod.env
# DB_PASS=swordfish

```

`lock` refuses while a declared file has uncommitted changes, and `--yes` does
not waive that — losing the key and losing unsaved work are different risks. It
refuses over anything else it cannot account for, too: another checkout of the
same repository, a directory it cannot read, and a leftover file it cannot
identify (see §Known limitations). Every one of those leaves the key in place
and the working tree untouched, so the way out is to fix what it named and run
it again.

### In CI


```yaml
- uses: actions/checkout@v5
  with:
    fetch-depth: 0                    # status needs full history

- run: git-xcrypt unlock --key "$GITXCRYPT_KEY"
  env:
    GITXCRYPT_KEY: ${{ secrets.GITXCRYPT_KEY }}

- run: git-xcrypt status              # the gate
```

Put the key there without it ever touching a disk:

```sh
git-xcrypt export-key --stdout | gh secret set GITXCRYPT_KEY
```

`--key` is visible in the process list while the command runs and is recorded by
an interactive shell; the command says so every time. See
[Handing the key to CI](#handing-the-key-to-ci-without-a-file).

### Keeping one file readable inside a secret directory


```gitignore
secrets/
!secrets/README.md
```

The negation wins, and the rendered `.gitattributes` line gives that one file
git's defaults back, so it is stored in the clear and diffed normally.
`git-xcrypt status` lists such paths in their own section, so an exception is
never invisible.

## The managed `.gitattributes` section


`init` writes two lines and nothing else:

```
* filter=git-xcrypt
* -text diff=git-xcrypt
```

Neither mentions a pattern, so neither can fall out of step with `.git-xcrypt`.
The `-text` is what keeps git's own CRLF conversion away from the ciphertext —
without it, any attribute declaring such a path `text` makes git rewrite the
encrypted bytes: measured on a 2 MB file, `git add` exits 0, the damaged blob is
committed, and the file is unrecoverable at checkout.

The cost of covering everything is the diff driver, which git spawns once per
blob — there is no long-running protocol for `textconv` as there is for filters.
Measured on git 2.55, against the same repository with the driver unregistered:

| files in the diff | what `init` writes | after `git-xcrypt sync` |
| --- | --- | --- |
| 5 | 72 ms | 21 ms |
| 20 | 201 ms | 22 ms |
| 1000 | 8461 ms | 23 ms |

An everyday diff pays nothing you would notice, so the two lines are fine to
keep. If your reviews routinely span hundreds of files, `git-xcrypt sync`
replaces them with a line per declared pattern:

```
* filter=git-xcrypt
**/secrets/** filter=git-xcrypt -text diff=git-xcrypt
*.env filter=git-xcrypt -text diff=git-xcrypt
```

That confines the diff driver to declared paths and lets git go on normalising
line endings everywhere else. The trade is that these lines *can* go stale, so
run `sync` after every change to `.git-xcrypt`. If you forget, the filter says
so on `stderr` the next time it encrypts something, without refusing the
operation:

```
git-xcrypt: .gitattributes no longer matches .git-xcrypt — run `git-xcrypt sync`.
```

`sync` also counts the lines outside its section that set `filter`, `text`,
`eol` or `crlf` and points at `status`: git takes the last match, so one of them
may outrank what `sync` just wrote, and only `status` resolves the attributes
far enough to say. `sync --global` goes back to the two lines. `sync --check`
exits **2** on a section that matches no shape this build writes, which makes it
usable as a CI gate — the same code, on the same state, that `status` gives:
a section that no longer covers every declared path is a setup that is not
enforcing what it declares. Exit 1 stays what it has always been, a usage error,
so a job can tell a stale section from a mistyped flag; before 2026-08-06 both
were 1 and `status` disagreed with `sync --check` about the state entirely.

### On a second machine

```sh
git-xcrypt export-key ~/git-xcrypt-my-project.key   # on the first machine
# carry the file across by whatever channel you trust

git clone <url> && cd my-project                    # on the second
git-xcrypt unlock ~/git-xcrypt-my-project.key
```

`git status` is clean immediately afterwards. That is the point of the
deterministic cipher: unchanged files never look modified.

## The key file is the only copy — back it up yourself


**Read this before you commit anything you cannot afford to lose.**

`git-xcrypt init` writes one 32-byte master key to
`.git/git-xcrypt/keys/default`. `.git/` is not versioned, is not pushed, and is
not part of any clone. Nothing in this tool copies that key anywhere, and
**v0.1 has no backup mechanism at all** — that is a deliberate scope decision,
not an oversight.

So:

- If the key file is lost, **every secret in the repository's entire history
  becomes unreadable, permanently.** Not just the current files: every version
  of every encrypted file in every commit, in every clone, forever. There is no
  recovery procedure and there is no one to ask.
- Losing it is easy. `rm -rf` on a working copy takes it. A reinstalled laptop
  takes it. `git clone` of your own repository does **not** bring it back.
  `git-xcrypt lock` deletes it on purpose, and `unlock` does not undo that.

Make a copy the moment you run `init`:

```sh
git-xcrypt export-key ~/backup/git-xcrypt-my-project.key
```

On Unix the file is written with mode `0600`. **On Windows nothing narrows it**
— it inherits whatever its directory hands down, so there the directory you pick
*is* the protection; see Known limitations. Where it should **not** go:

- **not inside the repository or any other checkout of it**`export-key`
  refuses those outright, because one `git add -A` would commit the key;
- **not into the git directory** — also refused;
- **not into a CI log or a terminal scrollback.** `export-key --stdout` prints
  to a terminal if you point it at one — the flag is the consent — and says on
  `stderr` that the key now lives in the scrollback, the multiplexer's buffer
  and any session log. A shell redirect it cannot police at all: see below.

Where it should go is somewhere that survives losing the machine and that you
trust with a plaintext secret: a password manager, an encrypted backup volume,
or an offline device. Treat it exactly as you would treat the secrets it opens —
because anyone holding it can read all of them, in every commit.

### Handing the key to CI, without a file


A runner's secret arrives as an environment variable, and writing it to disk
means remembering to delete it from a machine that may not outlive the job. The
two ends meet without a file:

```sh
git-xcrypt export-key --stdout | pbcopy      # paste into the secret store
git-xcrypt export-key --stdout | gh secret set GITXCRYPT_KEY
```

```yaml
- run: git-xcrypt unlock --key "$GITXCRYPT_KEY"
  env:
    GITXCRYPT_KEY: ${{ secrets.GITXCRYPT_KEY }}
```

Both forms carry the same text a key file holds, so the header still verifies
the material behind it: a key truncated by a clipboard or a variable is refused,
not installed.

**Three costs, all yours to accept knowingly.** `--key` puts the material in
`argv`, so it is visible to `ps` for as long as the command runs — measured on
macOS: `ps -ww -o command -p <pid>` prints it verbatim — and an interactive
shell records it in `~/.zsh_history` for good. The command says so on `stderr`
every time. And `export-key --stdout > somewhere` is not checked at all: a
process cannot portably learn the path behind its own file descriptor, so none
of the refusals that keep a key out of the working tree apply to a redirect.
For a file on disk, use `git-xcrypt export-key <path>`, which does check.

`git-xcrypt lock` asks for a typed `yes` and prints the `key_id` before deleting
the key, and refuses outright when declared files have uncommitted changes.
Those are speed bumps in front of the cliff. They are not a backup.

## Commands

| Command | What it does |
| --- | --- |
| `init` | Generate the repository key, register the filter and the diff driver, create `.git-xcrypt`, write the managed `.gitattributes` section. |
| `sync` | Rewrite the managed `.gitattributes` section as one line per declared pattern. `--global` writes instead the single line `init` starts with, which covers everything and cannot go stale; `--ignorecase` spells every ASCII letter as a class. `--check` reports staleness through exit code 2 instead of writing. |
| `status` | Report whether your declarations are actually enforced, scanning the whole reachable history. `--fix` re-stages declared files the index holds in the clear. Exits `2` when the setup does not enforce anything, `5` on a finding, `6` when it could not tell. |
| `export-key` | Write the repository key to a file outside the working tree. This is also how you make the backup nothing else makes — see above. `--stdout` pipes it instead, for a secret store; a terminal gets it too, and is told the key now sits in the scrollback. |
| `unlock` | Decrypt the working tree and register the filter, installing a key first if one is given — as a path, or as `--key <text>` for a CI secret. `--key-only` puts the key in place and repairs the setup without decrypting anything. |
| `lock` | Encrypt the working tree and delete the key. Interactive by default; `--yes` skips the question but not the refusal on uncommitted changes. |
| `diff`, `process` | Registered by `init` for git to call. Not meant to be run by hand. |

Exit codes: `0` success, `1` usage or unclassified failure, `2` configuration or
state conflict — including a `status` run that found the setup does not enforce
anything, `3` no key, `4` bad format, `5` `status` found an exposure, `6`
`status` could not tell.

### Using `status` as a CI gate

```yaml
- uses: actions/checkout@v5
  with:
    fetch-depth: 0        # required: see below
- run: git-xcrypt status
```

What the exit code means to the job:

| Code | Meaning | What to do |
| --- | --- | --- |
| `0` | Everything was checked and nothing was found. | Nothing. |
| `2` | **Fix the configuration.** Git is not set up to enforce your declarations here: the filter is not registered, `required` is not true, the catch-all line is gone, `.git-xcrypt` is missing, a declared path resolves to some other `filter`, or an attribute line outranks the managed `-text` and lets git convert the ciphertext. | Fix the setup, then run it again. Read the rest of the report too — a `2` does **not** mean nothing else was found. |
| `5` | Something was found in the data: a declared file staged in the clear, or a plaintext version in reachable history. | Read the report. If a secret leaked, **rotate it first**. |
| `6` | The run could not answer. A shallow or partial clone, an index that will not parse, a reference store that will not enumerate. | Fix the checkout and run it again. Nothing was found, and nothing is ruled out. |
| `1``4` | The tool itself failed — bad arguments, not a repository, no key, bad format. | Fix the invocation or the environment. |

**Treat `2`, `5` and `6` alike as a failed gate.** They ask for three different
repairs — fix the setup, rotate a secret, fix the checkout — and only `0` means
the question was answered and the answer was clean.

**Configuration comes before data, so `2` outranks both other answers.** A
repository whose setup enforces nothing cannot be called clean whatever its
blobs look like, and telling a checkout that never ran `init` that "an exposure
was found" sent people hunting a secret that had never been exposed. The code
never hides anything: a repository that is both misconfigured *and* leaking
exits `2` while printing the leak, the paths and the rotate-first procedure
exactly as it would under `5`, and says so on the verdict line. Fix the setup,
ask again, and the leak comes back as `5`.

**`fetch-depth: 0` is not optional for a full answer.** `actions/checkout` clones
with `--depth 1` by default, and history that was never fetched cannot be
scanned — so the default setup exits `6`, honestly, rather than passing on a
history it never saw. The same applies to `--filter=blob:none` partial clones.

A finding always outranks an unanswered question: a run that both hit an
unreadable index and found a leak exits `5`. A setup gap outranks both, so the
full order is `2`, then `5`, then `6`, then `0`.

## What it does and does not protect


**Encrypted:** the contents of every file a pattern selects, with AES-256-SIV
(RFC 5297) and a 32-byte master key that never leaves `.git/`.

**Not hidden:** file names, paths, sizes and the fact that a file changed. The
size leaks exactly — an encrypted blob is 38 bytes plus the content. Because
encryption is deterministic, two files with identical contents are visibly
identical, and a file reverting to an earlier version is visible as such. These
are accepted trade-offs of the construction, not defects.

**Not protected against:** a compromised machine. After `unlock`, secrets sit in
the clear on disk.

### The one risk worth reading twice


A secret committed **before** its pattern reached `.git-xcrypt` stays in history
in the clear, forever, and pushing sends it to the host. `git-xcrypt status`
scans the whole reachable history for exactly this and exits `5` when it finds
something — `6` when it could not look at all, and `2` when the setup is broken
enough that fixing it comes first; none of those are the same answer, and the
report names the leak under every one of them. If a leak is reported: **rotate
the secret first.** Rewriting history cleans the
repository but does not undo the leak — the secret is already in forks, caches,
CI logs and every clone that exists.

`status` answers "are my declarations enforced", not "does this repository hold
secrets". A file no pattern ever matched is invisible to it.

### Attributes that turn the filter off, or turn conversion back on


The managed section is a few attribute lines among many, and git takes the
**last** match. A line below the section, a `.gitattributes` in a subdirectory,
or `.git/info/attributes` — which is not versioned, so nobody reviewing a pull
request can see it — outranks it. There are two ways that hurts, and they hurt
differently:

- **`-filter` on a declared path.** Git runs no filter, `git add` stores the
  plain text with exit code 0, and the secret is in the repository.
- **`text`, or a bare `eol=`, on a declared path.** Git *does* run the filter and
  then converts the line endings of what it produced — the **ciphertext**.
  Measured on git 2.55: 34 `CR` bytes eaten out of a 2 MB blob, `git add` and
  `git commit` both exit 0, and the next checkout fails the authentication tag
  and leaves no file at all. Nothing is exposed; the file is simply gone, and no
  key will ever bring it back. This is what the managed `-text` prevents — the
  line `init` writes covers every path, the ones `sync` writes cover the
  declared ones.

  **Since 2026-08-05 the filter refuses this outright**, so the sentence above
  describes what *would* happen rather than what does. Git converts the filter's
  output, which means that when the filter is asked, nothing is damaged yet: it
  resolves the same attribute stack `status` does, and answers `git add` with an
  error naming the file and line number of the line that outranks the managed
  `-text`. With `required = true` the `git add` stops there and no blob is
  written. A refused commit is the cheapest outcome available; the alternative
  was a file nobody can decrypt again.

  **If the line arrives after the commit, the checkout says so — since
  2026-08-05.** The refusal above has nothing left to stop there: the blob was
  written while the attributes were still right, and it is intact. But git
  converts on the way *out* too — the order is blob, then git's conversion, then
  the filter — so the authentication tag is handed bytes that were never stored,
  fails, and git reports `smudge filter git-xcrypt failed` with no file in the
  working tree. That used to be printed as `the file has been altered`, which is
  a false alarm at the worst possible moment: nothing is altered and nothing is
  lost. The filter now recognises the case and prints the line number that
  caused it, says outright that the object database is untouched, and tells you
  to delete or narrow that line, run `sync`, and check the file out again. The
  verdict itself is unchanged — the bytes really are not what was encrypted, so
  they are refused, exactly as a tampered file would be.

`git-xcrypt status` resolves both attributes for every declared path the index
holds, using git's own precedence rules, macros included, and fails with exit `2`
either way — both are setup gaps, and the remedy is the attribute line — the report names the winning line and the file and line number it
sits in. It resolves rather than guesses, so none of these trigger it: an
ordinary `*.psd filter=lfs`, `text=auto`, `binary`, `-text` with any `eol=`, or
`core.autocrlf` at any value. Our magic starts with a NUL byte, so every code
path in git that consults binary detection leaves the ciphertext alone.
`git check-attr filter text eol -- <path>` gives the same answers by hand.

A foreign `diff=` line on a declared path is measured harmless: it costs you a
readable `git diff` and touches no stored byte. `status` does not fail over it.

The boundary: only paths the index already tracks are resolved. A line that
would disable the filter for a file nobody has committed yet is reported as a
note, not a finding — which is exactly why the conversion half of this lives in
the filter as well. On a brand-new file `status` has nothing to resolve and
exits 0, and the first thing that would have told you was the failed checkout.

## Known limitations


- **A failing filter blocks every git operation in the repository.** `init` sets
  `filter.git-xcrypt.required = true` on purpose: without it git ignores a
  filter failure and stores the plaintext with exit code 0. The cost is that a
  missing or unrunnable `git-xcrypt` binary stops `git add`, `git checkout` and
  `git status` with `fatal: … filter 'git-xcrypt' failed`. To get moving again,
  put the binary back, or unregister the driver by hand:
  `git config --unset filter.git-xcrypt.process` and
  `git config --unset filter.git-xcrypt.required`. Anything committed while it
  is unregistered is stored in the clear.
- **A clone that has not been unlocked is not safe to write to.** `.git/config`
  is not versioned, so a fresh clone carries the catch-all `.gitattributes` line
  with no driver behind it, and git treats an undefined filter as no filter.
  `git-xcrypt status` detects this and exits `2`: fix the setup with
  `git-xcrypt unlock <key-file>`, then ask again. A shallow clone of the same
  repository exits `6` once it is unlocked: nothing is wrong with it, but the
  history it never fetched cannot be vouched for.
- **Real git only.** The filter is registered under the long-running protocol
  (`filter.<driver>.process`). Clients that reimplement git rather than calling
  it — JGit, and tools built on libgit2 — may not speak it and may treat the
  file as unfiltered. IDEs and GUIs that shell out to `git` are fine.
- **Patterns fold ASCII case, and only ASCII case.** `secrets/` in `.git-xcrypt`
  covers `Secrets/db.env` and `SECRETS/db.env`, and `*.env` covers `top.ENV`  unconditionally, on every platform, whatever `core.ignorecase` says. That is
  deliberate: on macOS and Windows `secrets` and `Secrets` are the *same*
  directory, so a mis-spelled name cannot be seen at all, and reading
  `core.ignorecase` would make the same repository encrypt different files on
  different machines. Beyond ASCII nothing folds: `łąka/` does not cover
  `ŁĄKA/`. Git has the same limit — with `core.ignorecase=true` its own patterns
  do not fold non-ASCII letters either — and `.gitattributes` matches bytes, so
  the generated line has no way to spell such a fold. If your paths carry
  non-ASCII letters, declare each spelling you actually use.
- **The files that bootstrap the tool are matched with case folded too**, so
  `.GITATTRIBUTES`, `.GIT-XCRYPT` and `.Git-Xcrypt-Keys/` are never encrypted,
  whatever your patterns say. On a case-insensitive filesystem `.GITATTRIBUTES`
  *is* the attributes file, and encrypting it would switch the filter off for
  the whole repository. The cost on a case-sensitive filesystem is the other
  way round: a file you deliberately named `secrets/.GITATTRIBUTES` stays in the
  clear.
- **The system-wide attributes file is not consulted.** Besides the sources
  this tool resolves, git reads `$(prefix)/etc/gitattributes` — a path baked
  into each git build: Homebrew's git answers `/opt/homebrew/etc/gitattributes`,
  Apple's answers `/etc/gitattributes`, and `git var GIT_ATTR_SYSTEM` prints
  yours. That path cannot be learned without asking a `git` process, which a
  self-contained filter must not do — measured, it does not follow from
  `GIT_EXEC_PATH` for either macOS git — and resolving a *guessed* path that the
  running git does not read could refuse a healthy `git add`, which
  `required = true` turns into an outage. So the check-in refusal and `status`
  are blind to exactly this one source: a `text` line there that reaches an
  encrypted path converts the ciphertext with no gate firing, the same damage
  the global-file case describes. None of the inspected installations ships the
  file by default. If your machine has one, keep encrypted paths out of it, or
  export `GIT_ATTR_NOSYSTEM=1` so git itself stops reading it.
- `git archive` exports ciphertext: git does not apply filters to it.
- Submodules have their own configuration and need their own `init`.
- `working-tree-encoding` (character-set conversion, e.g. UTF-16) is not
  supported.
- **A declared file that arrives with CRLF comes back with LF**, unless you say
  otherwise. Declared paths are treated as `text=auto` — git's own default is to
  leave an unattributed path alone unless `core.autocrlf` says otherwise — so the
  filter normalises on the way in, and the header records only *that* it
  normalised, never which ending was there. Nothing can restore it afterwards,
  and `git status` stays clean, because the new bytes normalise to the plaintext
  already stored. Declare the path `binary` in `.git-xcrypt` to store it verbatim,
  or `eol=crlf` to have every checkout write CRLF. The other direction is closed:
  with `core.autocrlf` false or unset and `core.eol` unset — the configuration in
  which git converts nothing — a declared path now receives the stored bytes
  unchanged rather than the platform's own ending, so declaring a file no longer
  expands its `LF` on Windows. Set `core.eol=native`, or `eol=native` on the
  pattern, if you want the platform's ending back.
- **A killed `unlock` can leave a decrypted file behind, under a name no
  pattern was written for.** Files are replaced by writing a sibling and
  renaming it, so a process killed outright — `SIGKILL`, a crash, the power
  going — can leave `<name>.git-xcrypt-<16 hex>.tmp` next to the file it was
  writing. On the `unlock` path that leftover holds **plaintext**. `lock` sweeps
  the ones it can identify and says so; it leaves a file whose target nothing
  declares, with a note, because deleting somebody else's file is worse. One
  shape it refuses over instead: a name at the 255-byte filesystem limit, where
  the target it was built from was cut short and no longer identifies anything
  `lock` then exits `2`, keeps the key and changes nothing, because it cannot
  tell whether that file is a secret. Look at it, delete it if it is leftover,
  move it aside if it is yours, and run `lock` again. After any killed `unlock`
  it is worth looking for `*.git-xcrypt-*.tmp` yourself: `git status` will show
  them as untracked, and they do not match the pattern that would have
  encrypted them.
- **`eol=` reaches only the files the filter normalises.** It applies to content
  stored as text; a file the content rule reads as binary — a NUL byte is
  enough — is stored verbatim and every checkout writes those bytes back, `eol=`
  or no `eol=`. So one pattern can honour `eol=crlf` for one file and not for
  the next one beside it. The filter names any file this happens to, on `stderr`
  and only that file; add `text` to the pattern if it should be converted anyway.
- A file with mixed line endings does not survive the round trip: normalisation
  is lossy, so such a file comes back with one kind of ending. The filter says so
  on `stderr` when it first encrypts such a file, and `git status` will **not**  the changed bytes normalise to the plaintext already stored, so the file looks
  untouched. Give the file one kind of line ending, or declare it `binary` in
  `.git-xcrypt` to store it verbatim. This is what git covers with
  `core.safecrlf`, and the question here is narrower on purpose: git warns
  whenever the bytes would change, so with `core.autocrlf=true` it flags every
  LF-only file; it can afford that because the setting is off by default. This
  warning has no switch, so it fires only when the original could not be
  restored at all.
- **Key files are only given permissions on Unix.** `init` and `export-key`
  create them with mode `0600` there, before a single byte of key material
  reaches the file. On Windows nothing sets permissions at all: the file
  inherits the ACL of the directory it is created in, and narrowing it would
  need `unsafe` platform bindings, which this crate forbids outright. The
  repository's own key lives in `.git/` and is therefore as protected as the
  rest of your checkout — but an exported key is exactly as protected as the
  directory you chose for it, so on Windows choose one only your account can
  read.
- **There is no key backup mechanism.** Keeping a copy of the key file is
  entirely your job, and losing it costs the whole history of secrets. See "The
  key file is the only copy" above; this is a decided scope boundary for v0.1,
  not a gap waiting to be filled before release.
- A repository encrypted with the original `git-crypt` is **not** supported and
  there is no migration path.

## Building


```sh
cargo install git-xcrypt     # from crates.io
cargo install --path .       # from a clone
```

Requires Rust 1.88 or newer (the crate declares this as its MSRV and CI holds it
there). The binary is self-contained: no external libraries and no child
processes, `gpg` included.

Being named `git-xcrypt` and on `PATH` also makes `git xcrypt <command>` work.

Hardware AES needs nothing from you. The cipher crate compiles the AES-NI
backend on x86-64 and the ARMv8 one on aarch64, and picks between hardware and
software at **runtime** by asking the CPU, so a build from a clone, a published
release binary and `cargo install git-xcrypt` all run the same one. A CPU
without the extensions falls back to the constant-time software backend rather
than trapping. What that is worth, measured on `aarch64-apple-darwin`,
`--release`: an 8 MB blob through `git-xcrypt diff` takes 148 ms on the software
backend and 9 ms on the hardware one.

Nothing stored changes either way — both backends compute the same AES, and the
frozen format vectors pass on both. Only speed differs.

## Verifying a downloaded release


Every published archive carries a GitHub build provenance attestation. To check
one before you trust it:

```sh
gh attestation verify git-xcrypt-v0.1.1-<target>.tar.gz --repo rkarpin1/git-xcrypt
```

That answers *which commit and which workflow run produced this file*, which is
more than a bare signature would. There is no key to fetch and none to trust:
the attestation is bound to the workflow's own identity.

What it does **not** answer is whether these bytes follow from that source. The
build is not reproducible — the builder's own paths and the compiler version
reach the binary, so nobody can rebuild it and compare checksums. That is a
settled decision rather than a gap waiting to be closed: reproducibility is out
of scope for this project. If it is the guarantee you need, build from source;
`cargo install --path .` gives you a chain you control end to end, and it needs
nothing from us.

The `.sha256` file beside each archive is for spotting a truncated or corrupted
download. It is not a security check: anyone who can replace the archive can
replace the checksum next to it.

## Attribution


`git-xcrypt` is *inspired by* [AGWA/git-crypt](https://github.com/AGWA/git-crypt)
(GPL-3.0) and [AprilNEA/git-crypt-rs](https://github.com/AprilNEA/git-crypt-rs)
(MIT OR Apache-2.0). It is **not a port of either**.

No code is taken from either project. Command naming, the clean/smudge working
model and the general UX are kept compatible; the encrypted file format, the key
format and recipient management are our own. A repository encrypted with the
original `git-crypt` is **not** supported and there is no migration path.

## License


Licensed under either of

- Apache License, Version 2.0 ([LICENSE-APACHE]LICENSE-APACHE or
  <https://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT]LICENSE-MIT or
  <https://opensource.org/licenses/MIT>)

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in this crate by you, as defined in the Apache-2.0 license, shall
be dual licensed as above, without any additional terms or conditions.