monochromatic-git-policy-cli 0.2.7

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
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
# monochromatic-git-policy-cli

A Git wrapper that enforces repository-owned policies.
It is installed as `git` ahead of the real Git on `PATH`,
forwards every command to the real Git,
and checks the commands and the commits that pass through it against policies the repository configures as data.

The executable is `cli-git-native`.
The library inside the crate is named `git_policy_cli`.

## What it does

Every Git command goes through the wrapper first.
Read-only commands,
 and commands the wrapper has no rule for,
 run the real Git unchanged.

- Rules before forwarding.
  `require-root` demands the repository top level as the working directory.
  `linked-worktree-only` keeps every command form that can discard uncommitted work out of the main worktree,
   and out of a linked worktree while it holds uncommitted work (see "Discarding commands").
  `branch-worktree-only` sends branch creation through `git worktree add`.
  `add-explicit` rejects bulk staging patterns such as `git add .`.
- Commits as transactions.
  A `git commit` is prepared in a private index and a shadow repository.
  Hooks run there,
   the content policies check the exact bytes,
   and only then is the commit landed on the real index and
  branch.
  When another commit lands first,
   the wrapper replays its own commit on top of the new state.
  Concurrent commits from several worktrees or sessions therefore never overwrite each other.
  After an explicit-path commit lands, the wrapper compares the real index before and after the landing.
  Any staged entry outside the committed paths that differs is named in a warning,
   so a loss of staged work is reported instead of silent.
  A transaction that an interrupted process left behind is recovered by the next invocation.
- Content policies.
  `final-newline`,
   `markdown/autofix`,
   `mono/forbidden-root-context`,
   `mono/dependent-version-bump` and
  `security/forbidden-strings` check or rewrite what a commit contains.
  The same content policies scan what a manual `git push` newly publishes.
- After a landed commit.
  The wrapper checks the committed bytes again,
   then pushes the branch.
  A branch without an upstream is pushed to `origin` with `--set-upstream` when that remote exists.
  A branch with an upstream is pushed to it.
  When the remote refuses that push because its branch moved after the commit landed,
   the wrapper merges the remote tip into the branch tip from Git objects alone and pushes the merge without force,
   at most three times.
  While the merge is computed and pushed, the local branch, the index, the worktree and untracked files are not read
   or changed, so another session's uncommitted files cannot block the push.
  Afterwards the local branch follows the published merge by a fast-forward when nothing can be lost:
   a worktree with uncommitted work follows too, as long as the move writes none of the files that work touches,
   and the report says the work was left as it is.
   Otherwise the branch stays behind and the report names the overlapping files.
  A conflict is reported with the conflicting paths and publishes nothing.
  An amended or rebased branch is not merged, and its rejection is shown as before.
  A failed push never fails the commit,
   and no configuration key turns the push off.
  Automatic maintenance,
   as native Git runs it after a commit,
   runs after each landing,
  because a landing stores its objects in one new pack.
- Linked worktrees.
  When a Git command creates a linked worktree,
   the wrapper copies the ignored state of the source worktree into it
  under one settlement lock per repository.
- Signals.
  The wrapper catches `SIGINT`,
   `SIGTERM`,
   `SIGHUP` and `SIGQUIT` while it waits for Git.
  A signal sent by another process is relayed to the Git child,
   and the wrapper then releases its locks and removes
  its transaction instead of leaving it for recovery.

## Requirements

- Linux with glibc 2.39 or newer.
  Release archives exist for `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu` only.
  They are built on the `ubuntu-latest` runner and link `pidfd_spawnp` and `pidfd_getpid`, which glibc added in 2.39
  (measured on the 0.2.2 x86_64 archive with `objdump -T`), so they do not start on Debian 12 or Ubuntu 22.04.
  The crate does not compile for Windows: `cargo check --target x86_64-pc-windows-gnu --lib` fails with 16 errors in
  eight files (the `tempfile` and `ignore` dependencies are unix-only and several modules use `std::os::unix`),
  so the Windows-specific branches in the sources have never run; each is marked `UNTESTED ON WINDOWS` in the source.
  A Windows port is deferred to a later version (owner decision, 2026-10-15).
- Real Git 2.55.0 or newer on `PATH` after the wrapper.
  The wrapper checks the version before work that depends on Git's behavior,
   such as a commit,
  and refuses older Git.
  The wrapper finds the real Git by walking `PATH`,
   skipping itself and any shim that leads back to it.
- `monochromatic-lint` on an absolute `PATH` entry,
   only when `markdown/autofix` is configured.
- The scanner is linked into the executable and needs no separate installation.

## Install

The crate is published on crates.io.
Install it as a `cargo:` tool of [mise][mise],
 the way the repository installs `monochromatic-lint`:

```toml
# mise.toml
[tools]
"cargo:monochromatic-git-policy-cli" = { version = "0.2.7", os = ["linux"] }
```

`mise install` downloads the release archive of the crate's GitHub release when one exists for the host.
Otherwise it compiles the crate from source with the Rust toolchain.
`cargo install monochromatic-git-policy-cli --locked` compiles the same executable into `~/.cargo/bin`.

The installed file is `cli-git-native`,
 not `git`.
Link it as `git` in a directory that comes before the real Git on `PATH`:

```sh
# Run in the repository once per checkout, after the installation.
mkdir --parents .cache/cli-git-bin
ln --symbolic --force "$(mise which cli-git-native)" .cache/cli-git-bin/git
```

Never name a file `git` inside the tool's own `bin/` directory.
`mise` would then create a `git` shim for the whole account,
 and every Git command would run through the wrapper.

`PATH` shadowing covers only processes that find `git` through `PATH`.
An absolute path to the real Git,
 a `PATH` without the link directory,
 a GUI with an embedded Git library and direct
libgit2 consumers bypass the wrapper completely,
 including its recovery of interrupted commits and its policies.

## Commands

The wrapper adds one namespace to Git.

```sh
# Check or fix the whole repository, or the named pathspecs.
git cli-git check --all
git cli-git fix -- docs/ README.md
git cli-git check --all --policy final-newline
git cli-git --help
```

Exactly one scope is required:
 `--all`,
 or pathspecs after `--`.
`--policy <id>` limits the run to one policy and may be repeated.
Events are JSON lines on standard output.

The former `trust`,
 `untrust` and `status` commands are retired.
Configuration is data,
 so nothing needs approval.
They print an explanation and change nothing.

### Wrapper-only options

A wrapper-only option is removed before Git sees the arguments and applies to that one invocation.

- `--no-enforce-<policy id>` skips one policy,
   for example `--no-enforce-final-newline` or
  `--no-enforce-security/forbidden-strings`.
- `--no-enforce-worktree` skips `linked-worktree-only` for the forms listed in "Discarding commands".
  Write it directly after the subcommand,
   for example `git restore --no-enforce-worktree -- <path>`.
- `--no-enforce-worktree-branch` skips `branch-worktree-only`.
- `--no-enforce-bulk-add` skips `add-explicit`.
- `--no-worktree-copy` skips the ignored-state copy into a new linked worktree.
- `--cli-git-keep-going` reports every finding of the command in one run instead of stopping at the first blocking one.

### Hints

Every block and every divergence from real Git's behavior says what you were probably trying to do and how to proceed.
A JSONL event (a finding, an engine failure or a warn-unsafe notice) carries the hint in its own `hint` field,
 beside a `message` that is unchanged from the retired wrapper's text.
A plain notice (an internal error, a push stopped by its deadline or its output cap, a lock wait that gave up,
 a failed worktree copy, a real Git that cannot start, a commit that normalization emptied) ends with the hint.
The rejections that already carry their own alternatives (`require-root`, `linked-worktree-only`,
 `branch-worktree-only` and the commit-only rules) keep them in the message.
`src/native/event_hints.rs` holds the texts, and its tests fail when a finding code or an engine failure code has no hint.

### Discarding commands

`linked-worktree-only` rejects a command form that can overwrite or delete uncommitted work by itself.
The rule is:

- In the main worktree such a form is always rejected,
   clean or not.
- In a linked worktree it is rejected while the worktree holds uncommitted tracked changes
   (for `git clean`, also untracked files),
   and runs when the worktree is clean.
- A form that only moves `HEAD` (`git checkout <branch>`, `git switch <branch>`) is rejected in any worktree
   only while it holds uncommitted tracked changes.
- A status Git cannot read stops the command instead of reading as clean.
- Repositories under an allowlisted tool cache are exempt.

Every rejection names the form,
 what it would destroy,
 the alternative that fits the most likely intent,
 and the deliberate way to discard anyway.
The deliberate way is always the same:
 save a copy (`git diff > backup.patch`) and pass `--no-enforce-worktree` directly after the subcommand.
`git apply backup.patch` brings the edits back.

Each decision about a discarding form (rejected, run in a clean worktree, run in an exempt cache, or run with the
 escape) is appended as one JSON line to `cli-git-audit.jsonl` in the common Git directory,
 with the time, the process and its parent, the worktree kind, the form, the working directory and the arguments.
When uncommitted work disappears, `git rev-parse --git-common-dir` locates the file;
 a line that says `allowed` or `hatch` near the time of the loss names the command that was let through.
The file moves aside to `cli-git-audit.jsonl.1` at one mebibyte, and a failed write never changes a command's result.

The forms and their alternatives follow.
Each alternative is run through the built executable by `src/native/binary_discard_tests.rs`.

- `git stash` (every form except `list`, `show` and `create`):
   commit work in progress with explicit paths on a topic branch in its own linked worktree
   (`git worktree add -b <branch> <path>`).
- `git clean` without `--dry-run`:
   `git clean -n` lists what would go,
   delete specific files with `rm`,
   or use the project's own clean task.
- `git reset --hard`, `--merge`, `--keep`:
   `git reset --soft <commit>` undoes a commit and keeps its changes,
   `git diff <commit>` compares,
   `git worktree add <path> <commit>` gives a clean copy.
- `git reset` in the default or `--mixed` mode:
   `git commit --message <message> -- <paths>` commits only the named paths and ignores other staged entries,
   `git diff --cached --name-only` lists what is staged.
- `git restore --staged` (unstaging):
   the same two commands as `git reset`.
- `git restore` and `git checkout` with paths,
   `--force`,
   `--patch`,
   `--ours` or `--theirs` (reverting files):
   `git diff -- <path>` and `git show HEAD:<path>` compare,
   `git worktree add <path> <branch>` opens another branch,
   or save a patch and use the hatch.
- `git checkout <branch>` and `git switch <branch>` while the worktree is dirty,
   and `git switch --force`:
   commit with explicit paths first,
   or open the branch with `git worktree add <path> <branch>`.
- `git rm --force`:
   `git rm <path>` refuses a modified file,
   and `git rm --cached <path>` stops tracking and keeps the file.
- `git mv --force`:
   pick a destination that does not exist,
   or `git rm <destination>` first.
- `git checkout-index --force`:
   `git checkout-index --prefix=<directory>/ --all` writes copies elsewhere.
- `git read-tree --reset`:
   `git read-tree -m` merges,
   or reset in a clean linked worktree.
- `git merge --abort`,
   `git rebase --abort`,
   `git cherry-pick --abort`,
   `git revert --abort`,
   `git am --abort`:
   the same command with `--quit` stops the operation and keeps the index and files,
   `--continue` finishes it after resolving conflicts,
   and `--skip` drops the current commit or patch.

The forms are read with Git's own option grammar,
 so abbreviations,
 clustered short flags (`-rf`) and a path after `--` are handled as Git handles them.
Commands without a table of their own (`rm`,
 `mv`,
 `checkout-index`,
 `read-tree`,
 `restore` and the `--abort` commands) take the hatch directly after the subcommand.

### Output and exit status

Policy events of a wrapped command are JSON lines on standard error,
 one object per line,
 with `schemaVersion` 1.
Real Git keeps the normal standard streams.

- When real Git does not run:
   `0` means clean or warnings only,
   `1` means final error findings,
   and `2` means a usage,
  configuration,
   transaction or engine failure.
- When real Git runs,
   its exit status is preserved.
  A landed commit that a post-commit policy then blocks exits `2` and stays local.

## Configuration

The wrapper reads `cli-git.config.jsonc` at the repository top level.
The file is JSON with comments,
 at most 1 MiB,
 and it is data:
 it never names code to run.
Without the file,
 the defaults apply.
The former TypeScript wrapper read `cli-git.config.mjs` and `cli-git.config.ts`;
 those files are never executed or read.
With no JSONC file beside them,
 they are a configuration failure that asks for the translation.
Beside a JSONC file,
 they are reported and ignored.
The keys `plugins` and `trust` are refused.

```jsonc
// cli-git.config.jsonc
{
  "policies": {
    // A severity alone: "off", "warn" or "error".
    "final-newline": "error",
    // A severity with options.
    "markdown/autofix": ["warn", { "rules": ["lfs-image-url"], "exclude": ["package/ssg/"] }],
    "mono/forbidden-root-context": "error",
    "mono/dependent-version-bump": "error",
    "security/forbidden-strings": ["error", { "builtinRules": true, "rulesFile": ".cache/forbidden-strings.rules.txt" }]
  },
  "hooks": { "concurrentCommits": false },
  "indexLock": { "unprovenOwnerTimeoutMs": 1000 },
  "landing": { "reserveAfterLostRaces": 1 }
}
```

An unknown key,
 a wrong type or an unknown policy id is a configuration failure that exits `2` before any policy runs.

### Policies

The wrapper ships these policies and no others.
The first five run at their default severity without being listed.
The last four run only where the file lists them.

- `require-root`:
   default `error`.
- `linked-worktree-only`:
   default `error`.
- `branch-worktree-only`:
   default `error`.
- `add-explicit`:
   default `error`.
- `final-newline`:
   default `warn`.
  Text files end with exactly one line feed.
- `markdown/autofix`:
   default `warn`.
  Runs `monochromatic-lint --fix` over staged Markdown inside the commit.
  Options:
   `rules` (only `lfs-image-url` exists) and `exclude` (gitignore-style patterns).
- `mono/forbidden-root-context`:
   default `error`.
  No `CONTEXT.md` at the repository top level enters a commit.
- `mono/dependent-version-bump`:
   default `error`.
  Packages that depend on a package with a bumped version follow the bump.
- `security/forbidden-strings`:
   default `error`.
  Scans candidate bytes and path names with the linked scanner.
  Options:
   `builtinRules` (boolean,
   load the scanner's built-in baseline) and `rulesFile` (a rules file path relative
  to the repository top level).
  The rules file is `rulesFile`,
   else the environment variable `FORBIDDEN_STRINGS_RULES`,
  else `forbidden-strings.local.txt` in the repository top level,
   which may be absent.
  A file named by `rulesFile` or by the variable must exist.

A `warn` setting reports findings without blocking.
A policy whose protection `warn` would remove produces a configuration warning event when the file sets it to `warn`.

### Concurrency

- `hooks.concurrentCommits`:
   boolean,
   default `false`.
  `false` serializes the hooks of concurrent commit transactions;
   `true` lets them overlap.
- `indexLock.unprovenOwnerTimeoutMs`:
   non-negative integer,
   default `1000`.
  How long the wrapper backs off for an `index.lock` whose owner is dead or unproven.
- `landing.reserveAfterLostRaces`:
   positive integer,
   default `1`.
  How many lost landing races pass before a transaction asks for the landing reservation.

The concurrent transaction itself cannot be turned off.

### Waiting and time limits

The wrapper never waits for another process without a limit.
Three environment variables set the limits,
each a positive whole number of seconds;
a zero,
negative,
fractional or non-numeric value is ignored.

- `CLI_GIT_LOCK_WAIT_SECONDS`,
   default `1800`.
  How long a command waits for a lock that a live process holds:
   the capture,
   landing,
   hook and registry locks,
   the landing reservation,
   the worktree-copy settlement and a live `index.lock` holder.
  After five seconds of waiting the command prints one line naming the process and the limit.
  At the limit it stops with a message that names the holder,
   leaves the lock in place and changes nothing else,
   so the command can simply be run again.
- `CLI_GIT_PUSH_DEADLINE_SECONDS`,
   default `600`.
  How long the push after a landed commit may run before it is stopped.
  The commit stays saved,
   the failure is reported,
   and `git push` can be run by hand.
  Git's own read-only queries before the push stop at this deadline or 120 seconds,
   whichever is shorter.
- `CLI_GIT_PUSH_LOCK_WAIT_SECONDS`,
   default the push deadline plus 65.
  How long a commit waits behind another process's push before it gives up and reports its push as failed.

A command that passes through to Git unchanged,
 and the user's own hooks,
 run as long as they run and receive Ctrl-C like any Git command.

## State on disk

The wrapper keeps its state beside the repository's Git data under `cli-git*` paths in the common Git directory.
Delete nothing there while a Git command is running.
The next invocation recovers what an interrupted one left.

The ignored-state copy into a new linked worktree stages its files in a `.cli-git-worktree-copy-*` directory
beside the new worktree, which can hold gigabytes.
The wrapper records that directory's name in the common Git directory before it creates it,
so a copy that was killed while staging cannot leak it:
the next invocation in the repository removes the directory and says so on stderr.
Delete a `.cli-git-worktree-copy-*` directory by hand only when no `git` command is running in that repository.

## Development

The commands below run in the repository that holds the crate,
[Aquaticat/Monochromatic][repository],
 from the repository top level.
Every task runs through `mise`.

```sh
# Run from the repository top level.
mise run //package/git-policy/cli:native:check
mise run //package/git-policy/cli:native:clippy
mise run //package/git-policy/cli:native:build
mise run //package/git-policy/cli:native:test:host -- <test name filter>
mise run //package/git-policy/cli:native:test:binary:host -- <test name filter>
mise run //package/git-policy/cli:native:test:container
```

- `native:check` type-checks every target.
  `native:clippy` lints every target with warnings denied.
  `native:build` writes the debug executable to `package/git-policy/cli/target/debug/cli-git-native`.
- `native:test:host` runs the unit tests and `native:test:binary:host` runs the tests that start the built executable,
  both against the host's Git while iterating.
- `native:test:container` is the authority.
  It builds the crate and runs the tests and Clippy in bounded containers with Git 2.56.0 and no network.
  Set `GIT_POLICY_NATIVE_IMAGE_TAG=<name>` to keep the images of parallel runs apart.
- `native:perf` builds the release executable and measures the latency it adds to forwarded Git commands and to
  explicit-path commits, with paired direct-Git samples in disposable repositories.
  It reports the median, p95 and median absolute deviation of both sides and the added delta,
  fails when a forwarded command's added p95 reaches the 2,000 ms bound or any sample fails,
  and `-- --output <file>` stores the raw samples (baselines live in `perf/`).
- `native:lock:members` records a changed dependency list or version in `Cargo.lock` without moving any other locked
  version.

A release build of the executable:

```sh
# Run in package/git-policy/cli.
cargo build --release --locked --bin cli-git-native
```

The release profile keeps `panic = "unwind"` and `overflow-checks = true`.
The linked scanner turns a matcher panic into a failing finding by catching the unwind,
and the crate refuses to compile under `panic = "abort"`.

## License

`LGPL-3.0-or-later`.
The license texts are in `LICENSES/`.

[mise]: https://mise.jdx.dev/
[repository]: https://github.com/Aquaticat/Monochromatic