monochromatic-git-policy-cli 0.5.32

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
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
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
# 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`.

How the owner wants this package designed and judged is in [DESIGN.md](DESIGN.md).

## 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 landed by a service.
  A `git commit` is prepared in a private index,
   the content policies check the exact bytes,
   and only then does the commit service of the worktree land it on the real index and branch
   by compare-and-swap.
  The service is started by the operating system on the first commit (socket activation);
   the wrapper never starts it,
   and on a machine where no service can be reached or started a commit stops with a plain message and changes nothing.
  When another commit lands first,
   the wrapper prepares its own commit again on top of the new state.
  Concurrent commits from several worktrees or sessions therefore never overwrite each other.
  The wrapper takes no lock of its own: a commit meets only Git's `index.lock`,
   which the service holds for the branch move and the index build only (a median of 5 ms in the measurement in `SPEC.md`).
  A commit the service was landing when it died is settled by the service from its journal when it starts again.
  A stale `index.lock` that a killed service left (its marker names a process that is gone) is removed by one reclaimer at a time,
   on every file system: reclaimers take their place in a chain of numbered claim files (`cli-reclaim-*`) beside the lock and check that
   the name still refers to the file they judged before they unlink, so no `flock` is involved. A killed reclaimer can leave inert
   claim files, which `SPEC.md` ("The claim chain") says how to remove.
  A marker carries the machine ID of its host: a reboot of the same machine ends the holder, and a lock or claim of a service on another
   machine (a repository on a shared file system) is never taken for stale. Neither is a marker of an earlier release, which names no
   machine and is reclaimed by that release's own exclusion: such a lock, or a claim that cannot be judged, stays, the commit waits as for
   any held lock, and the notice names the file and gives `rm -- '<path>'` for a person who knows the service is gone. A stale lock that a
   service of an earlier release left is removed by hand once after the upgrade; every lock this release writes reads
   `cli-git-service v2 ...`, which an earlier release leaves alone.
  A lock that cannot be proven stale is never removed (a running Git's lock, or one held by a process of another PID namespace).
- Merges that do not fail (the owner's rules of 2026-11-03: "Auto merging should never fail." and
   "Manual merge should never fail too.").
  Native Git's merge stashes first and dies when another process holds `index.lock`,
   changes the checkout before it moves the branch, and refuses every `git merge` that creates a commit
   while the index holds any staged change, even when the merge touches none of the staged files.
  The wrapper waits for the lock, lands the merge on a branch that moved meanwhile,
   and computes the merge from Git objects,
   commits it with the parents, message and signing native Git would use,
   and lets Git's own fast-forward update move the checkout to it.
  Every staged entry, unstaged edit and untracked file that the merge does not change stays byte for byte as it was.
  A merge that changes a path someone else has staged or modified, or that conflicts,
   is refused with the paths named and the ways forward, and changes nothing.
  Shapes the wrapper cannot make exact (`--squash`, `--no-commit`, `-s`, `-X`, several branches, an editor) run in
   native Git unchanged; see "Merging" for the full list.
- Package-manager state in a new worktree.
  After `git worktree add` has filled a worktree and copied its ignored files,
   the wrapper starts each tool's own offline repair there:
   `pnpm install --offline --frozen-lockfile` for the copied `node_modules`,
   a clone of the source's external Cargo build directory followed by `cargo clean --package <member>` for each workspace member,
   and `mpm-shadow install --frozen-lockfile` for toolchain launchers that still name another checkout's lock.
  A repair never fails the add: when a tool is missing, offline data is incomplete or a lock disagrees,
   one line names the command to run (see "Repairing package-manager state in a new worktree").
- Hooks.
  cli-git supports no external hook program: not a traditional hook (an executable script in `.git/hooks` or in a
   `core.hooksPath` directory) and not a hook configured with `hook.<name>.event` and `hook.<name>.command`.
  Every real Git the wrapper starts, forwarded commands and its own children alike, runs with
   `-c core.hooksPath=/dev/null/cli-git-no-hooks`, a directory that cannot exist, and `-c hook.<event>.enabled=false`
   for every hook event Git knows, after your global options and before the subcommand, so no `-c` of yours brings a
   hook back.
  The wrapper injects no hook of its own, so `pre-push` is switched off like every other event.
  A script that starts `/usr/bin/git` directly, outside any Git the wrapper started, still runs hooks.
  The wrapper runs no program that any configuration names: what a hook checked is a cli-git policy under `policies`
   in `cli-git.config.jsonc`, and there is no setting that brings a hook back.
  A `postCommit` key in `cli-git.config.jsonc` is a configuration error that says so.
  A commit in a repository that configures hooks prints one non-blocking `configuration-warning` event
   (code `external-hooks-unsupported`) naming the keys, the events and the ways forward,
   and `git cli-git doctor` lists the same hooks.
  A commit in a repository that sets `core.hooksPath` prints one such event too (code `traditional-hooks-unsupported`).
  The shims `git lfs install` writes are the expected case: see "Git LFS" for what replaces them.
- Git LFS.
  Every push the wrapper starts uploads the LFS objects it publishes pointers for, before any ref moves,
   in a repository whose own Git configuration (local, worktree or `-c`; not the global file) has a `filter.lfs.*` or
   `lfs.*` key.
  When the pushed commits add pointers the step is `git-lfs push`, which the wrapper runs itself as a child before it starts the push;
   the push carries no hook, the wrapper never starts itself and writes no shell text.
  The remote and refs come from the push's arguments (everything for `--all`, `--tags`, `--mirror` and glob
   refspecs; the branch Git would send for a bare push), and a failed upload stops the push before it starts.
  The wrapper, as the parent that ran the upload, prints the explanation.
  The wrapper first reads the pushed commits itself, with its in-process readers, and finds the pointer files they add
   (compared with each parent, so a pointer added and removed again inside the push still counts).
  A push that adds no pointer starts no `git-lfs` and sends no request to the LFS server; a push that adds some runs
   `git-lfs push --object-id <remote> <oid>...` for exactly those objects.
  The remote's tracking refs say what the destination already has only for a configured remote that is fetched from
   and pushed to one place; a push URL, a `pushInsteadOf` rewrite, a URL or path destination, a mirror push or an
   unconfigured remote makes the wrapper read the whole history of the pushed refs instead.
  A repository the readers decline, or an object missing from `.git/lfs`, falls back to `git-lfs push <remote> <refs>`
   (with `--all` in the untrusted cases), which scans itself.
  When `git-lfs push` fails, the wrapper does not run it again, whatever the cause.
  A server error (an HTTP 5xx answer other than 501, 507 and 509, which `git-lfs` reports on a line starting
   `Fatal error: `) is outside our control, like a network that is down.
  That is pick 12 of `doc/planning/cli-git-shipped-limits-audit.md`, made by the coordinator under the owner's delegation
   and open to the owner's veto.
  The commit service's push retry plan (`CLI_GIT_PUSH_RETRY_SECONDS`, doubling to 600 seconds) tries the auto-push again
   by itself, and a typed `git push` ends at the first server error, with `git-lfs`'s own words, so that you push again.
  The wrapper reads the standard error of `git-lfs` through a private temporary file and relays it with the user and
   password of every URL replaced by `<credentials>`.
  It covers a typed `git push` (including the merge push of a lost race), the auto-push after a commit and its
   reconcile pushes.
  `--dry-run` pushes and the wrapper's own probes never upload.
  `--no-verify`, `GIT_LFS_SKIP_PUSH` and `-c hook.cli-git-lfs-pre-push.enabled=false` skip it.
  Without `git-lfs` on `PATH` the push stops before anything starts and the message says to install it, remove the
   LFS configuration or use `git push --no-verify`.
  The other three hooks `git lfs install` writes (`post-checkout`, `post-commit`, `post-merge`) only keep lockable
   files read-only, do nothing without the `lockable` attribute, and are not run.
  See the SPEC section "Git LFS upload before a push" for the evidence and the limits.
- Content policies.
  `final-newline`,
   `markdown/autofix`,
   `mono/forbidden-root-context`,
   `mono/dependent-version-bump`,
   `mono/catalog-specifiers` 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,
   including a branch whose upstream has another name, such as a linked worktree branch created from `origin/main`:
   Git itself refuses that push under its default `push.default=simple`, so the wrapper names the tracked branch.
  The resident service pushes the branch its record names and never reads `HEAD`.
   It sends the branch only where a plain `git push` of that branch would send it:
   a `remote.<name>.push` refspec is applied to the local refs the way Git applies it
   (an explicit refspec before a pattern, the first matching pattern, a negative refspec last),
   and `push.default` decides otherwise.
   Under `push.default=matching` the service runs the plain `git push <remote>` and lets Git match,
   so every local branch whose name exists on the remote is pushed, not only the recorded one,
   and a branch deleted on the remote is not recreated.
   When Git refuses the setting or would push the branch nowhere
   (several `branch.<name>.merge` values under `simple` or `upstream`, `push.default=nothing`,
   a triangular `upstream`, two refspecs that send different branches to one destination,
   a refspec that names a ref that does not exist, a refspec that does not cover the branch),
   nothing is pushed and the note names the branch, the setting and the way forward.
  The same push is handed over after a `git merge`, a `git pull` that merges, a `git cherry-pick`, a `git revert` and a `git am`
   that succeeded, when the branch tip moved forward and the branch is now ahead of its upstream and not diverged from it.
  The check reads the branch, the upstream tracking ref and the commit parents from the files and starts no Git.
  A rebase is not handed over (a rebased branch would need a forced push), `git pull --rebase` and `pull.rebase` included,
   and neither is a branch with no upstream, a diverged branch, a conflict, or a command that moved nothing.
   `--continue` of a stopped cherry-pick, revert, am or merge is the command that adds the commit, so it is handed over.
  Every decision,
   handed over or not,
   leaves a line with its reason in `cli-git-push-trigger.jsonl`
   in the common Git directory
   (`"reason":"not-ahead"` and the like),
   so a push that did not happen can be explained from the file.
   The file is shared by every worktree, so each line names its `worktree`, `gitDir` and `instance`,
   and the branch when it is known;
   a failed command's line carries the branch and the tip it was at before the command.
   `level` is `warn` for a step that could not decide or a hand-over that failed and `info` otherwise,
   and the userinfo of a URL in `detail` is masked.
  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 ten 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 that the word-level second merge pass cannot resolve in every file is reported with the conflicting paths
   and publishes nothing; when it resolves every conflicted file, the merge is published and its commit message and the report
   list each part merged by word (see "Word-level second merge pass").
  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 as work of the commit service,
  so a commit returns before it.
- A manual push that lost a race.
  When another session pushes to the same branch between your commit and your `git push`,
   Git rejects the push as non-fast-forward.
  The wrapper runs a plain push of one branch as a child process with your terminal,
   so Git's own output is unchanged.
  When that push fails and the remote branch has commits yours lacks,
   the wrapper merges them in from Git objects alone and pushes the merge by running itself,
   so every push policy reads the integrated result,
   at most ten times.
  A conflict is reported with the conflicting paths and publishes nothing, after the same second pass.
  The checked-out branch follows the published merge under the same conditions as after a commit;
   a detached HEAD stays where it is, and the report says so.
  Forced, deleting, mirroring, tag, `--all`, `--set-upstream`, `--porcelain` and `--dry-run` pushes,
   and pushes of a `+refspec`, are never integrated,
   and a failure that is not a divergence (authentication, offline, a refusing hook) keeps Git's own report.
- Linked worktrees.
  When a Git command creates a linked worktree,
   the wrapper copies the ignored state of the source worktree into it
  without any lock: the destination comes from the command line, the copy is a transaction of its own whose journal and
  stage claim name the process that owns them, and a copy a dead process left is recovered by the next command.
  Concurrent adds in one repository never wait for one another, and each worktree receives exactly its own copy.
  The wrapper reads the destination of every form Git's option parser accepts (abbreviated long options resolved as Git
   resolves them, bundled short options, `--git-dir` and `--work-tree`, a destination below directories Git creates);
   a form Git rejects keeps Git's own error, and a form the wrapper cannot read is made by Git alone with one line on
   standard error saying the worktree received no copy.
  `git worktree add` in the plain forms (`-b <branch>`, `--detach`, `--quiet`, `--lock`, `--reason`, one path and at most
   one commit-ish) is served by the first rung of a ladder that applies and otherwise falls to the next; when the add
   succeeds without being a snapshot, one line on standard error (unless `--quiet`) and one journal entry say why, in plain
   words, and what makes the next add quick (each step leaves a line in the diagnostic log, `MONOCHROMATIC_VERBOSE=true`):
  1. A btrfs snapshot of the worktree the command is run from (the main checkout included) when it is a clean subvolume
     on the destination's filesystem.
     Real Git runs with `--no-checkout`, the snapshot is made beside the destination, gets the new worktree's `.git`
     pointer and the source's index with refreshed stat fields, has the difference between the source's tree and the
     new `HEAD` applied by `git read-tree -m -u`, and is swapped into place with `RENAME_EXCHANGE`.
     The snapshot shares every data block with its source and carries the source's whole ignored state
     (`node_modules` included), so the ignored-state copy is skipped for it.
     A main checkout with uncommitted changes (staged, edited, deleted, renamed, mode-changed or untracked files) is
     snapshotted too: in the new worktree only, `git read-tree --reset -u` and `git clean --force -d` make it equal to what
     `git worktree add` makes from a clean checkout, the result is proven before the worktree is handed over, and the
     changes stay in the main checkout, which is never written. A failed proof falls to Git's own checkout and says why.
     A merge or other operation in progress, a held `index.lock`, a skip-worktree or assume-unchanged entry, a submodule
     and a changed `.gitattributes` file still send the add the slow way, and so does a dirty linked worktree.
  2. Clones (`FICLONE`) of every tracked file of a clean worktree, with its index, on btrfs, XFS with reflink and
     overlayfs. It shares disk but takes about as long as Git's checkout.
  3. Git's own checkout, then the ignored-state copy.
  A source is clean when no operation is in progress, its index equals the tree of its `HEAD`, every tracked file has
   the size, times and inode its index entry recorded, and Git lists no untracked file that it does not ignore.
  Rungs 1 and 2 are declined for a sparse checkout, a worktree-specific configuration, a submodule, `.gitattributes`
   files that differ between the source and the new `HEAD`, and `--force`, `--no-checkout`, `-B`, `--orphan`, `--track`,
   any `-c` option, and `--no-worktree-copy`; rung 1 from a linked worktree is also declined while a nested linked worktree is
   an ordinary directory (`git cli-git migrate-to-subvolume` or `git cli-git convert-worktree-to-subvolume` makes them
   subvolumes). A snapshot of the main checkout does not decline for one: it sets those worktrees, other checkouts, caches
   and run records aside in a trash directory under `.claude/` and the commit service empties it afterwards, deleting
   only the directories the snapshot itself moved there (recorded with their identities) and never following a link.
   Those directories are emptied side by side, within the threads and memory the host allows.
   It does decline when a registered nested worktree holds a file the main checkout tracks. A trash that cannot be
   emptied is reported with its path and the reason, and its work item stays for the service. Another process that
   replaces or relinks directories of the new worktree while this runs is a risk the person accepts by using the
   command: only the cheap checks (no followed links, identities compared on opening) guard against it.
  The line reads like `cli-git: this worktree was not made as a snapshot, so it took longer, because the main checkout has
   uncommitted changes in 2 paths (for example notes.md). Commit or stash them, and the next add is a snapshot that
   takes seconds.` It tells uncommitted changes, a running Git command, an unfinished operation, an index that cannot yet
   vouch for its files (`git status` once fixes it), a source that is no subvolume, and a snapshot that started and failed;
   `journalctl --identifier=cli-git` finds the same entry afterwards, with the fields `CLI_GIT_WORKTREE`,
   `CLI_GIT_SLOW_CAUSE` and `CLI_GIT_SLOW_DETAIL`. On a filesystem that is not btrfs it says nothing.
  Every worktree the wrapper creates on btrfs is a subvolume, so rung 1 needs no migration after the first one.
  Git's `post-checkout` hook runs on no rung, because every real Git start of the wrapper switches hooks off.
  `git worktree remove <directory>` (no option) destroys a clean, unlocked subvolume worktree with one ioctl and deletes
   its administrative directory when the mount has `user_subvol_rm_allowed` (the doctor prints the commands that set it);
   in every other case Git removes it.
  It does so only for a worktree that the repository you run it in registered and that Git would pick for the name you typed
   (Git matches the name against the last components of every registered path before it resolves the path); an empty name,
   another repository's worktree and a name that Git resolves differently go to Git, which words its answer.
  A subvolume destroy cannot be undone, and the command prints a line that says so each time it destroys one: a write, a
   lock or a rename that another process makes in the moments between the last check and the destroy is destroyed with the
   worktree, as it is when Git deletes the files one by one. Do not run it on a worktree something else is still using.
  If the files are destroyed but the registration cannot be removed, the command fails with status 2 and a report that
   names the leftover `.git/worktrees/<name>` directory and the command that finishes the removal
   (`git -C <repository> worktree prune`); it does not run Git afterwards.
  From the main checkout the copy carries the dependencies and builds a new worktree needs to run at once
   (`node_modules`, `target/`, `dist/`, local configuration files and the repository's `.cache/`),
   and leaves out caches, the records of earlier runs and any directory that is another checkout.
- 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 ends without leaving state of its own behind.

## Other processes changing files

The wrapper checks files, copies them and, in a few commands, removes them, and no check or lock it takes can stop another
process from changing them in the same moment.
A rename or a replacement of a path, a write through an open file or a shared mapping, and an edit between a check and the step
that relies on it are outside what the wrapper guards against, in every command and in the commit service.
Running the program is accepting that risk.
The cheap guards stay (a lock where one is free, a second look before a removal, an identity check on what is removed), but
they narrow the window and do not close it.

## 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 on `PATH` after the wrapper, tested with 2.56.0 (`MINIMUM_GIT_VERSION` in `src/native/invocation/git_version.rs`).
  The wrapper checks the version before work that depends on Git's behavior,
   such as a commit.
  A Git older than that release is not refused:
   the wrapper prints one warning that names the Git it found,
   the release it reported and the release the wrapper was tested with,
   says that the command continues,
   and runs the command as usual
   (owner decision, 2026-11-04: "Raise minimum, drop the test, merely print a warning on older git and not refuse.").
  Only a Git whose release cannot be read stops the command.
  The constant follows the test image's Git:
   the gate image installs the distribution's current `git` package,
   so when `mise run //package/git-policy/cli:test:git-image` reports a newer release,
   raise the constant to it with the first gate run on that release.
  The wrapper finds the real Git by walking `PATH`,
   skipping itself and anything that leads back to it:
   a link or a copy of the wrapper,
   its toolchain launcher (recognised by the `.launch` record beside it),
   and a script that starts the TypeScript wrapper.
  When `PATH` offers none, it uses the Git recorded for its commit service, with a notice (see "Install").
- `git-lfs` on `PATH`, only in a repository whose own Git configuration has `filter.lfs.*` or `lfs.*` keys.
- The scanner and the Markdown linter are linked into the executable and need 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 = "latest", 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.

The wrapper never starts itself as Git.
If every `git` after the wrapper on `PATH` leads back to cli-git (the link directory comes alone, or the real Git's
directory is missing), it falls back to the real Git that its commit service was set up with, when the unit file of
the service (`cli-git@.service` below `$XDG_RUNTIME_DIR/systemd/user`) names one in its `--git` option and that file
is an executable that does not lead back to cli-git.
It then runs your command through that Git and writes one line on standard error that says so and which directory to
add to `PATH` to silence it.
This fallback is the coordinator's pick under the owner's delegation, open to the owner's veto
(`doc/planning/cli-git-no-self-invocation.md`).
Without a usable record (no unit yet in this login, or a unit that names the wrapper, a launcher of it, or a missing
file), it starts nothing and stops with exit status 2.
The message lists the `PATH` it read and every candidate with the reason it was skipped, names the record when one was
refused,
and says how to fix it: install Git, or put the real Git's directory (for example `/usr/bin`) on the `PATH` of the
program that ran the command.
Every start of the real Git sets `CLI_GIT_STARTED_AS_GIT`, so a cli-git executable that a wrapper started as Git
(another release, or a launcher without a record) stops on arrival with a message saying so;
a hook, alias or editor that the real Git runs may still run an installed wrapper.
The rule and the incident behind it are in `doc/planning/cli-git-no-self-invocation.md`.

`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 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
# List the hook files and configured hooks the repository holds; none of them runs.
git cli-git doctor
# Convert the main checkout into a btrfs subvolume (preview first); see the section below.
git cli-git migrate-to-subvolume --dry-run
git cli-git --help
```

The wrapper never starts its own executable, never writes shell text or scripts, and explains a failure from the
parent process that owns the child (see `doc/planning/cli-git-no-self-invocation.md`).
A Clippy rule bans `std::env::current_exe` and `src/native/source_guard/no_self_invocation_tests.rs` scans the source for the rest;
nothing is on its exception list that starts the wrapper.

Exactly one scope is required:
 `--all`,
 or pathspecs after `--`.
`check` and `fix` read the worktree files the scope selects,
 unstaged changes included;
 a commit or a push checks the staged or committed content instead,
 so a violation that exists only in the worktree fails `check` but not a commit that leaves it unstaged.
`--policy <id>` limits the run to one policy and may be repeated.
A selected policy that is off stops the run with exit `2`,
 because it would check nothing and read as clean:
 a `config-invalid` event on standard output names the policy
 and the `cli-git.config.jsonc` key that turns it on
 (`"policies": { "<id>": "error" }`,
 or the value the file now sets to `"off"`).
This holds for the four older optional policies as well,
 which used to exit `0` with no output in that case.
A selected policy that is on but does not run for the command is refused the same way,
 with the command that does run it:
 `fix --policy mono/catalog-specifiers` stops with exit `2` and points to `check`.
Without `--policy` an off policy is skipped without a word,
 and so it is when a commit,
 an add or a push runs.
Events are JSON lines on standard output.

`git cli-git doctor` takes no options.
It scans `<git-common-dir>/hooks` and the directory a configured `core.hooksPath` names,
 and prints the path of every traditional hook it finds, one per line, on standard output:
 each executable regular file and each symbolic link, except names ending in `.sample`.
A hook file that is byte for byte the shim git-lfs writes for its own name is not listed there:
 it is explained on standard error, with its path, as normal and harmless (git-lfs writes these files again whenever
 it cleans a file in a Git the wrapper did not start; the wrapper does the LFS upload itself and never runs them;
 delete them or leave them).
It exits 0 when it finds none or only such shims, 1 when it finds any other hook, and 2 when it cannot locate or read
 the repository.
Traditional hooks are not supported,
 so a script that still checks something should become a cli-git policy under `policies`.
It also lists the hooks configured with `hook.<name>.event` (one `hook.<name> (events: ...)` line each, exit 1) and
 notes a `hooks` key in `cli-git.config.jsonc`, which is accepted for older files and ignored.
A commit does not scan for traditional hooks: it only warns when the configuration sets `core.hooksPath` or defines
 hooks, which it already knows without any extra file system access.

The doctor also says whether Git's own fsmonitor is off, on standard error and before the filesystem section
 (see "Git's own fsmonitor"): one line when `core.fsmonitor` is false or unset, and a finding with the file, the scope
 and the command that turns it off when it is on.
It never changes the exit status and never writes configuration.

The doctor ends its standard error with a filesystem section for the main checkout of the repository Git selects
 (so `-C`, `--git-dir` and a linked worktree report the main checkout; a bare repository has none).
It is informational: it never changes the exit status and starts no program.
It reads `statfs`, the checkout's inode number, `/proc/self/mountinfo`, `/run/ostree-booted` and `/proc/cmdline`.
Every line of the section after the first is indented, and the first begins with `git cli-git doctor: filesystem:`.
Four cases are told apart:
 a checkout on a filesystem that is neither btrfs nor overlayfs gets one line saying that `git worktree add` uses Git's
 own checkout and no faster method applies;
 a checkout on overlayfs gets the detection, that cloning file data inside the overlay works and a snapshot does not
 apply, and no command;
 a checkout on btrfs that is an ordinary directory is told that making it a subvolume can help with performance
 (worktrees can then be made from btrfs snapshots, which took a median of 0.35 s to 1.09 s in the design measurements
 against 2.1 s to 2.7 s for Git's checkout, see `doc/planning/cli-git-btrfs-worktree.md`, and until then are made by
 clones of the tracked files) and is given `git cli-git migrate-to-subvolume --dry-run` and
 `git cli-git migrate-to-subvolume`;
 a checkout that already is a subvolume gets what a worktree add and a worktree remove do there, and the commands that
 set `user_subvol_rm_allowed` when the mount lacks it.
On btrfs it also checks the mount option `user_subvol_rm_allowed`, which lets a regular user delete a subvolume in one
 call.
When it is absent, the doctor prints the command to set it, for the host it finds:
 `sudo mount --options remount,user_subvol_rm_allowed <mount point>` until the next boot;
 on an ostree or bootc host, for the filesystem the kernel command line configures (the one mounted at `/sysroot`)
 with exactly one `rootflags=` word on the running command line,
 `sudo rpm-ostree kargs --delete=rootflags=<old> --append=rootflags=<old>,user_subvol_rm_allowed` (delete and append,
 because the value contains `=` and libostree splits each argument at the first `=`, which makes `--replace` read the
 old value wrongly), then a reboot;
 for any other filesystem, or on a conventional host, an `/etc/fstab` entry (or a systemd mount unit) for it.
Nothing is run for the person.

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

### Converting the checkout to a btrfs subvolume

`git cli-git migrate-to-subvolume` turns the repository's main checkout into a btrfs subvolume at the same path.
A subvolume is the precondition for creating worktrees from btrfs snapshots, and `git cli-git doctor` says when it would
 help.
It runs inside the wrapper: it calls the btrfs ioctls, `FICLONE`, `renameat2` with `RENAME_EXCHANGE` and its own tree
 walk, and it starts no program (no `cp`, `rsync`, `btrfs` or `mv`), so it needs no tool besides the wrapper.
Run it inside the repository, from the main checkout or from any linked worktree: it finds the main checkout by reading
 `.git` itself.
It takes minutes on a large checkout and there is no time limit on it.
One command at a time works on a checkout: it owns the checkout through a claim file beside the journal
 (`.cli-git-migrate-<name>.owner.<generation>`), a second command is refused with the owner's process id, and a claim a
 killed command left needs no cleanup.
The journal is replaced by writing a sibling file that is created exclusively under a name of its own (the journal's name,
 `.tmp`, the process number and a clock reading), synchronizing it and renaming it over the journal, so a link or a file
 planted or left at a fixed name is never followed, truncated or written through.
Releases up to 0.5.20 wrote one fixed sibling name and followed a link planted there.
The plain files a killed run left under the writer's own names (that fixed name included) are removed once a later write lands;
 a link, a directory or any other name beside the journal is left alone.

```sh
git cli-git migrate-to-subvolume --dry-run   # preflight and plan; nothing is created
git cli-git migrate-to-subvolume             # migrate; run it again after any stop and it resumes
git cli-git migrate-to-subvolume --rollback [--discard-unreadable]  # undo, finished or not
git cli-git migrate-to-subvolume --remove-old [--discard-unreadable]  # delete the original tree
```

The steps, each recorded in a journal beside the checkout (`.cli-git-migrate-<name>.journal.json`):
 create `<root>.subvol-new` as a subvolume;
 create each linked worktree inside the checkout as a nested subvolume (`--no-nested-worktrees` keeps them ordinary
 directories), so that a later snapshot of the root holds an empty stub for each;
 copy the tree, cloning file data so that the new tree shares every data block with the old one (only metadata is
 copied);
 repeat the copy as a delta that also deletes what the checkout lost meanwhile;
 hold a marked `index.lock` (Git's own lock for writing an index, the one lock that cannot be dropped) for the main
 index and for every linked worktree's index, in the original before the last delta and in the new tree before the swap, run the last delta and swap the two directories in one atomic call;
 rename the original to `<root>.before-subvolume`;
 carry over files that are newer in the original (writes that landed there after the swap), while the new checkout
 holds the locks;
 rewrite the stat fields of every Git index of the checkout and its linked worktrees from the copied files
 (`--no-refresh-indexes` leaves them; the first `git status` then hashes every file, and runs the Git LFS clean filter
 on LFS files); an entry is rewritten only when Git could trust it (its time is earlier than the index file's own) and
 the original file still has the stat fields the entry records, so a file edited behind Git's back stays dirty;
 release the locks and verify that the root is a subvolume with a readable `.git/HEAD`.
The original tree is never deleted by the migration.
One measured run, on a synthetic checkout of 121,260 entries (forty thousand tracked files in the checkout and in one
 linked worktree, forty thousand ignored files) under a scope of `CPUQuota=200%` and `IOWeight=20`:
 the copy took 8.34 s and 9.67 s in two runs, each delta pass 0.81 s to 1.02 s, the index rewrite 0.35 s and 0.40 s,
 and the whole migration 11.55 s and 14.74 s.
The copy cost about 70 to 80 microseconds per entry and a delta pass about 7 to 8 microseconds per entry; the pass under
 the locks is the delta pass, so they are held for that long, for the exchange, and now also for the catch-up and the
 index rewrite.
Each lock file holds a token that names the migration. A lock that another Git command holds when the migration needs
 it stops the migration with the file named, and is never taken over, exempted or removed; the migration's own locks are
 recognised by their token when a run resumes.
Every entry is written under a temporary name and renamed into place, so a stop never leaves a half-written file under
 its real name, and a file that changes while it is copied is copied again.

A process whose working directory is inside the checkout keeps that directory: after the swap its relative writes land
 in the original tree.
The preview and the run list such processes by number; stop agent sessions and editors first, and run `cd "$PWD"` in
 shells afterwards.

The command refuses, with the cause and every way forward, and changes nothing, when:
 the checkout is not on btrfs (overlayfs is named separately), is a mount point, is on another device than its parent
 or is a symbolic link;
 it already is a subvolume with no migration recorded;
 the parent directory has fewer free bytes than the metadata of the checkout's entries needs
 (2048 bytes per entry: one kibibyte measured per copied entry on btrfs, written twice by the `DUP` profile; the command
 lists every directory once to count the entries, and the refusal names the count and both byte figures);
 `<root>.subvol-new` or `<root>.before-subvolume` exists without a journal that explains it;
 Git is in the middle of an operation (any of `index.lock`, `MERGE_HEAD`, `CHERRY_PICK_HEAD`, `REVERT_HEAD`,
 `BISECT_LOG`, `rebase-merge`, `rebase-apply`, `sequencer`, `HEAD.lock`, `config.lock`, `packed-refs.lock` or
 `shallow.lock` in `.git` or in a linked worktree's administrative directory);
 the commit service has unfinished work in `.git/cli-git/work/pending` or `claimed`.
Exit status 0 means done, already done or previewed; 1 means a refusal; 2 means a step failed after the journal began,
 and the message says to run the command again to resume or to add `--rollback`.

Files this user may not read (for example root-owned mode 600 logs written by a privileged run) stay in the original
 tree and are listed in `.cli-git-migrate-<name>.unreadable.txt`, and the run says so loudly.
A directory this user cannot list stops the run before the swap, because copying around it with deletion on would
 erase its subtree from the new tree.
Because the original tree is then the only copy of the unreadable files, `--remove-old` refuses while the list is not
 empty, unless `--discard-unreadable` is given with it; copy the files over with the needed privilege first
 (for example `sudo cp --archive --reflink=always --parents`).

`--rollback` carries back files that are newer in the new root, swaps the trees again (recorded in the journal before the
 call, so a rerun after a stop never swaps twice), rewrites the restored indexes, removes the migration's own index
 locks and deletes the new subvolume, nested subvolumes first.
Files that only the new subvolume holds and this user may not read cannot be carried back, so `--rollback` refuses
 while there are any, unless `--discard-unreadable` is given with it.
`--remove-old` first carries into the checkout what was written to the original after the catch-up (files newer than
 their copy, and files the checkout lacks that are no older than the catch-up; older ones were deleted on purpose), then
 deletes the original.
Both deletions make the directories this user owns writable on the way down, so a mode-555 directory does not stop them.
Both refuse, before they delete anything, when a directory below the tree is a nested subvolume or a mount point, and
 name it: its content is not part of the tree.
 Move it out, delete it with `btrfs subvolume delete`, or unmount it, then run the deletion again.
Deleting a subvolume uses the kernel's destroy call where the mount has `user_subvol_rm_allowed` and otherwise empties
 it and calls `rmdir`, which the kernel allows for an empty subvolume root.

### Converting nested worktrees to subvolumes

`git cli-git convert-worktree-to-subvolume [--dry-run | --abandon] [--jobs <count>] (--all | <worktree>...)` turns a linked
worktree that is an ordinary directory into a btrfs subvolume at the same path, several worktrees side by side.
A snapshot of the main checkout shows a nested worktree that is a subvolume as an empty directory, so converting the ones
under `.claude/worktrees/` once removes the work of setting each of them aside at every `git worktree add`.

- The worktrees share nothing but the filesystem (each has its own administrative directory, index, claim file and journal), so
  they are converted side by side, as many at once as the host allows: the threads this process may run less an eighth of
  them (at least one, none when only two), no more than the free memory holds at the measured peak of one conversion, never
  more than sixteen (the measured point past which the filesystem gains nothing), and no more than `--jobs <count>` when it
  is given. The report is the one a run of one worktree after another gives: each worktree's
  name and lines together, in the order named (with `--all`, sorted by path), whichever finishes first, with the lines of the
  first unfinished worktree shown as they happen. A worktree named twice is converted once and then found converted.
- A worktree that fails after its journal began does not stop the others. It keeps its journal and says so, every other worktree
  is converted, the closing line counts the failures, and the status is 2; running the same command again finishes the failed
  ones. Releases before this one stopped at the first failure.
- `--dry-run` lists each worktree and either `would convert` or why it is skipped: locked, an `index.lock`, an operation in
  progress, a modified tracked file, a process with its working directory or an open file inside it, a worktree registered
  inside it, or a converting directory that no journal owns.
  Run it first; it changes nothing.
- Without `--dry-run` the command takes no lock. It owns the worktree through a numbered claim file in the administrative
  directory (`cli-git-convert.owner.<generation>`) that names its process; a second command that finds a live owner skips the
  worktree and names that process, and a claim a killed command left needs no cleanup. A conversion is also refused while a
  `migrate-to-subvolume` of the main checkout runs, and that command is refused while a conversion runs. Then it creates
  `<path>.cli-git-converting` as a subvolume and records its identity, copies the worktree into it with cloned data, takes the
  administrative directory's `index.lock` with its own token only for the rest (the long copy runs without it), proves the
  copy equal with a second pass, swaps the two directories with one exchange, checks that the path is the subvolume it created
  and has the same `.git` bytes, rewrites the index stat fields so the first `git status` stays fast, gives the `index.lock`
  back, and removes the old tree only when it is the directory it recorded and the installed worktree holds all of it.
  A `cli-git-convert.lock` file that release 0.5.20 or older left is removed when no command holds it, and a conversion
  that release left unfinished is completed by running the command again.
  The registration never changes: the path and the bytes of the `.git` file are the same.
- The old tree is kept, and the run says why, when a process still holds it, when anything in it differs from the installed
  worktree (size, modification time, kind, link target) or is missing there, or when it contains a subvolume or a mount point,
  which this command does not delete. Nothing is ever copied from the old tree into the installed one, so an edit made to the
  installed worktree is never overwritten; whoever knows which version is wanted settles it by hand and runs the command again.
- A quoted line in any attributes file, an attribute source chosen with `GIT_ATTR_SOURCE`, `attr.tree` or `--attr-source`, or a
  filter, `ident` or encoding attribute that may apply to a file whose recorded size and times cannot vouch for it, makes the
  command refuse that worktree and name the reason, instead of deciding by the file's content.
- Where Git reads its system configuration file and its system attributes file from is a fact only the real Git the wrapper
  forwards to can give (`git var GIT_CONFIG_SYSTEM`, `git var GIT_ATTR_SYSTEM`, learned once per executable and cached). The
  command resolves that Git itself, as every command that forwards does, because the wrapper puts the variable that names it
  only into the environment of the Git processes it starts, never into its own. Release 0.5.20 and older read their own
  environment, found no Git named, and skipped every worktree whose index entries the stat data could not vouch for with
  "the Git configuration cannot be read without running Git"; this release judges those worktrees by content. Where no real
  Git is found, such a worktree is still skipped with that reason, which is the safe side.
- Risk you accept by running it: it deletes the old directory, and it cannot stop another process from changing the worktree
  while it works (see "Other processes changing files").
  It says so once on standard error before it starts, and in `--help`.
- Run the same command again after any stop: it completes the conversion in place. `--abandon` undoes one that has not
  reached its swap, destroying only the subvolume its journal says it created.
- Measured on a fixture of four ordinary worktrees of 22.3 thousand entries each: 4.2 s per worktree under a scope of
  `CPUQuota=400%`, load average 6 to 10.
- Stop sessions that have a working directory in the worktree first; the command refuses while one does.

### Repairing package-manager state in a new worktree

A copy keeps symbolic links and build state exactly as they were,
 which is wrong for tools that record where they were installed or built.
pnpm's links climb out of the tree by a fixed number of levels to reach the shared store,
 so in a worktree at another depth they are dead,
 and Node then resolves the name through a parent `node_modules` to the source's packages without any error.
Cargo bakes the source's paths into what it built (`CARGO_MANIFEST_DIR`, build script output).
The toolchain launchers copied into `.cache/toolchain-launchers` name the source's `toolchain.lock.json`.
After the ladder (or Git's checkout) and the ignored-file copy,
 the wrapper starts each tool's own repair in the new worktree:

- pnpm: `pnpm install --offline --frozen-lockfile`, when `pnpm-lock.yaml` and `node_modules` are both there.
  The `pnpm` is the first one on your `PATH` (in this repository the pnpm-shadow, whose warnings are shown only if the run fails).
- Cargo: for each Cargo workspace,
   the source's external build directory is cloned to the worktree's when the worktree has none,
   then `cargo clean --offline --package <member>` runs for every member
   (a `Cargo.lock` the clean would rewrite is put back as checked out, so the worktree is not dirty).
  Registry and git dependencies stay reused; the worktree's own crates rebuild with their own paths.
  A file or directory in the source's build directory that you cannot read is left out of the clone and counted in the success line;
   the source is never touched.
  The workspaces are repaired side by side, on as many threads as your machine's available parallelism.
- Toolchain: `mpm-shadow install --frozen-lockfile`.
  It also downloads the tools the worktree's lock names that are not installed yet,
   and can retire tool copies another checkout still links (both accepted by the owner),
   and its summary of what was installed is part of the success line.

Nothing runs for `--no-worktree-copy` or `--no-checkout`.
`--quiet` silences the one line that reports success; a failure is always printed.
When a tool is missing, its offline data is incomplete, a lock does not match or the run exceeds its limit,
 the add still succeeds and prints one line that names the reason and the command to run,
 such as `pnpm --dir <worktree> install --offline --frozen-lockfile`.
Another process changing files while a worktree is copied or repaired is your risk and is not defended against.
Times measured on this repository are in `doc/planning/cli-git-main-worktree-ignored-copy.md`,
 section "Package-manager state of a new worktree".

### Version and help

The wrapper runs the real Git
 for the top-level `--version`, `-v`, `--help` and `-h`,
 so Git's own output and exit status come first and are never changed.
Only when Git exits with status 0 does the wrapper add plain text to standard output,
 so a person can tell that a policy wrapper sits in front of Git.
When Git exits with another status, or a signal ends it, nothing is added and the wrapper returns Git's status.
Global options before the query, such as `git -C <directory> --version`, behave the same.
A subcommand's own help, such as `git commit --help`, is untouched and so is every other query
 (`--exec-path`, `--html-path`, `--man-path`, `--info-path`, `--list-cmds=<group>`):
 the wrapper replaces itself with Git and their output is byte for byte Git's.

`--version` and `-v` add exactly one line,
 where `<version>` is this crate's version and `<path>` is the real Git the wrapper resolved:

```text
monochromatic-git-policy-cli <version>, shadowing git at <path>
```

`--help` and `-h` add a blank line and a short section:

```text
monochromatic-git-policy-cli <version> sits in front of this git
It adds repository policies on commit, push and worktree commands,
the `git cli-git` management command (`git cli-git --help`),
and `git cli-git doctor`, which lists the traditional hooks a repository holds.
real git: <path>
```

A script that compares the output of `git --version` with a fixed string should read the first line only,
 or call the real Git by its path.
The wrapper's own findings stay JSON lines as described under "Output and exit status";
 this text appears only for these four spellings.

### 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 and the whole worktree ladder: Git alone
  makes an ordinary directory.
- `--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/policy/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 directory are exempt: uv's cache (`UV_CACHE_DIR`, else
   `XDG_CACHE_HOME/uv`, else `~/.cache/uv`) and mise's data directory (`MISE_DATA_DIR`, else `XDG_DATA_HOME/mise`,
   else `~/.local/share/mise`), where mise keeps its plugin clones and updates them with a forced checkout.

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.

During a merge, a rebase, a cherry-pick or a revert there is one exception.
`git checkout --ours|--theirs -- <path>`, `git checkout <rev> -- <path>`, `git checkout --merge -- <path>` and the
 `git restore` forms with `--ours`, `--theirs`, `--source` or `--merge` run for conflicted paths only
 (the paths with an unmerged index entry),
 in the main worktree and in a linked one,
 after the wrapper saves your current file.
The copy goes to `cli-git/discarded/<UTC time>/<path>` in the common Git directory, beside `cli-git-audit.jsonl`,
 and the wrapper prints where before Git runs:

```text
cli-git: saved your copy of a.rs to
  /path/to/repo/.git/cli-git/discarded/2026-11-02T14-01-07/a.rs
  before this command replaces it (a.rs was conflicted).
```

A path that is not conflicted keeps the rejection,
 and a line that names conflicted and clean paths together is rejected whole with the clean paths named.
Write each conflicted file by its own name after `--`;
 a directory, a wildcard, pathspec magic, a missing `--`, `--force`, `--patch`, `--pathspec-from-file` and
 `restore --staged` are not covered and stay rejected.
 A token that is the value of `--source`,
 `-s` or `--conflict` is never read as `--worktree` or `--staged`,
 so `git restore --source -W --staged -- f` (a tag can be named `-W`) is an index-only restore and stays rejected,
 and the rejection names `git restore --staged`,
 not a restore of file content.
A conflicted path that is deleted in your worktree has nothing to copy, and the command runs.
When a copy cannot be written (no space, no permission), the command is rejected and says why.
 A symbolic link or a file where a directory in front of the name should be (`dir` for `dir/file`) is never followed:
 Git would replace it with a real directory and it would be lost unsaved,
 so the command is rejected,
 names `dir`
 and says to move it out of the way or save it first.
 A file that changes while it is being copied (an editor's atomic save,
 an append,
 a rewrite in place) is not reported saved:
 the copy is removed,
 the command is rejected,
 and running it again once nothing writes to the file goes through.
 A file that another program changes while the save runs is your risk.
 A pathspec mode (`--icase-pathspecs`,
 `GIT_ICASE_PATHSPECS` and the glob,
 noglob and literal forms) keeps the rejection,
 and when every path you named is a conflicted file the message names the mode and says the same command without it
 is saved first and then allowed.
 The same holds for pathspec magic on a conflicted file's name (`:(icase)a.rs`,
 `:!a.rs`,
 `:/a.rs`):
 the message names the magic and the file,
 and naming the file itself is saved first and then allowed.
The folder is bounded, because it would otherwise grow for as long as the repository lives,
 and the copies are the only remaining content of replaced files.
 When a new copy has been written and synced, the wrapper removes the saves older than 30 days,
 then, while the folder is over 1 GiB, the oldest saves first.
 A save younger than 24 hours is never removed by either limit, and neither is the save just written.
 Only directories named like the wrapper's own (`2026-11-02T14-01-07`) are touched.
 No commit waits for this, and no lock is taken: passes that overlap share a save's files, and each copy is audited once.
 The folder is never followed through a link: if `.git/cli-git` or `.git/cli-git/discarded` is a symbolic link,
 nothing is removed, and the command's notice and `git cli-git doctor` say so;
 make it a real directory to apply the limits again.
 A save that cannot be read, a directory replaced while the pass ran, a removal that fails part way
 and an audit file that cannot be written are all kept and named the same way, never counted as removed.
 Each removed save leaves one line in `cli-git-audit.jsonl`
 (`"outcome":"pruned"`, with the time, `path`, `bytes`, `reason` `age` or `size`,
 the `limit` it was judged against and the names of the copies in it).
 The limits are the `discardedCopies` section of `cli-git.config.jsonc`.
 The defaults are not yet based on a measurement of growth:
 on 2026-11-03 no repository on the development host had a saved copy,
 and a copy can be the only copy of your work, so the numbers are open to change
 (`doc/planning/cli-git-shipped-limits-audit.md`, "Defaults for saved conflict copies").
 An example that keeps saves for 90 days and the folder under 256 MiB:

 ```jsonc
 // cli-git.config.jsonc: keep saves for 90 days and the folder under 256 MiB
 { "discardedCopies": { "maxAgeDays": 90, "maxSize": "256m" } }
 ```

 A value that is no limit (zero, a fraction, text that is not a size) is a configuration failure that names the key;
 to keep copies longer, set a large number.
 `git cli-git doctor` reports the folder's size, the number of copies and saves, and the oldest save, once it holds any.

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 (`time` and `timeMillis`), a `level` (`warn` for a rejection), the release, the process and its parent,
 the worktree kind and the worktree's Git directory (`gitDir`), the form, the working directory and the arguments
 (a decision that saved conflicted files also lists where each copy went).
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.
At one mebibyte the file moves aside to the next free number (`cli-git-audit.jsonl.1`, then `.2`, and on; the highest is
 the newest) and a new file starts.
 Rotated files that name no saved copy still on disk are kept up to four mebibytes, oldest removed first;
 a rotated file with a `saved-conflict` line whose copy still exists is never removed.
 The four mebibytes are derived from the measured rate of this file (about 23 KB a day) and the 30 days copies are kept;
 the timing logs and the push trigger log keep one rotated file, `.1`.
 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/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.

### Merging

The owner's rules (2026-11-03): "Auto merging should never fail." and "Manual merge should never fail too."
Native Git's merge that creates a commit does two things that break them in a checkout many sessions share:
 it first runs `git stash create` on the index (`save_state()` in Git's `builtin/merge.c`; no stash ref is ever stored),
 which dies with `could not write index` and `stash failed` when another process holds `index.lock`,
 and it changes the index and the worktree before it moves the branch,
 so a branch that moved meanwhile (another session's commit, or the auto-push's own reconcile merge)
 leaves the checkout half merged with `fatal: update_ref failed for ref 'HEAD'`.
The wrapper therefore completes the merge itself whenever the command is one it understands,
 whether the index differs from `HEAD` (other sessions' staged work) or not.
Native Git also refuses, when the index differs from `HEAD`,
 a `git merge <branch>` that needs a merge commit with
 `error: Your local changes to the following files would be overwritten by merge`,
 whichever files are staged.
The wrapper completes that merge instead, when all of these hold:

- the command is `git merge` with one commit-ish and only the options `-m`, `--no-edit`, `--ff`, `--no-ff`,
   `-q`, `-v`, `--stat`, `--no-stat`, `-n`, `--log`, `--no-log`, `-S`, `--gpg-sign`, `--no-gpg-sign`,
   `--no-verify`, `--allow-unrelated-histories` and `--no-progress` (full spellings only);
- `HEAD` is not already ahead of or equal to the merged commit
   (native Git fast-forwards and answers "Already up to date" by itself);
- no merge, rebase, cherry-pick or revert is in progress, and the settings `merge.autostash`,
   `merge.verifySignatures` and `branch.<name>.mergeOptions` are unset;
- the merge would not open an editor: pass `--no-edit`, or set `GIT_MERGE_AUTOEDIT=no`
   (a merge started from a terminal opens one by default, and then native Git runs it as before).

The merge tree comes from `git merge-tree --write-tree`, the commit from `git commit-tree`, signed when `-S` is given
 or `commit.gpgSign` is true.
Its message is Git's own (`git fmt-merge-msg`, then the whitespace cleanup of a commit that opens no editor),
 and the checkout then moves with `git merge --ff-only <merge commit>`.
That is Git's two-way update:
 it writes exactly the paths the merge changes and keeps every other index entry and file.
The merge commit skips `pre-merge-commit`, `prepare-commit-msg` and `commit-msg`,
 like the merge the auto-push makes from objects;
 `post-merge` runs once.
The branch reflog reads `merge <branch>: Fast-forward`.

Two refusals change nothing:

- The merge changes a path that is staged, or modified in the worktree, by someone else.
  The finding `merge-isolation/paths-in-use` names the paths.
  The ways forward are to wait until the owner has committed or restored them,
   to merge in a clean linked worktree and bring the result back with `git merge --ff-only`,
   or, for your own files, to commit or stash them first.
- The merge conflicts textually, and the word-level second merge pass cannot resolve every conflicted file.
  The finding `merge-isolation/merge-conflict` names the conflicting paths.
  Resolve in a clean linked worktree or checkout, or rebase the branch.
  A conflict that does resolve by word completes the merge, and the lines of the merged parts come first on standard error.

An untracked file where the merge adds one is refused by Git at the last step;
 the finding `merge-isolation/not-applied` follows Git's message, and nothing is changed.
A merge killed while it runs is finished or discarded by the next invocation (see "State on disk").
A `git pull` that merges is completed the same way when the command line or the settings say that it merges
 (`--no-rebase`, `pull.rebase` or `branch.<name>.rebase` set to false, or `--ff` or `--no-ff`) and every option
 and operand is one the wrapper reads exactly:
 the wrapper runs the pull's fetch,
 then the merge of `FETCH_HEAD` as a typed `git merge --no-edit FETCH_HEAD` would run.
Every other pull is native Git's whole (a rebase, `--ff-only`, `--autostash`, `pull.ff`, submodules,
 a refspec with a destination,
 an abbreviated option, a pull with nothing stated, which Git refuses with its advice when the branches diverged),
 and it keeps native Git's failures (the held lock, the staged index, the late ref update);
 run `git fetch`, then `git merge --no-edit origin/<branch>`.

#### Waiting for the index lock

Before Git's fast-forward changes anything, the merge waits for any program's hold on `index.lock`
 (the commit service's, a foreign `git`'s, or a lock nothing proves held):
 until `LOCK_CEILING` (10 minutes), whether its owner is proven alive or not
 (the line for an unproven owner says what is known and how to remove the lock if no program uses the repository);
 at the bound it names the holder and the merge ends with Git's refusal, non-zero, with nothing changed.
The notice names the merge, says it has changed nothing yet, names the lock and the holder with its proof
 (an open descriptor or Git's PID file), and repeats every `CLI_GIT_WAIT_NOTICE_SECONDS`.
No lock is ever removed.
Native Git, measured in a fixture, never waits: a held lock fails the merge in a few milliseconds.
A merge that native Git runs (a conflict, an editor) waits for the lock first in the same way.

#### A branch that moved while the merge was prepared

The merge commit is built on the head the merge was computed on, and Git's fast-forward moves the branch only from that head.
When the branch moved first, Git's fast-forward refuses without changing anything,
 or, if it lost the race in the instant between its checkout and its ref update,
 the paths it installed are put back to the moved head's content
 (only paths that hold the old, the merged or the moved head's content; any other content stops it
 with the paths named and the record kept).
The merge is then computed again on the moved head and lands as a merge commit whose first parent is that head,
 up to twenty times in a row; each round says so on standard error.
No `MERGE_HEAD` is ever left behind by a merge that did not finish.

#### A conflict

With another session's staged work, the word-level second pass (see "Word-level second merge pass") runs on the conflicts
 of the merge computed from objects, and the merge completes when every conflicted file resolves;
 otherwise it is refused (`merge-isolation/merge-conflict`) and nothing changes.
With a clean index the merge is native Git's, which writes the markers for the person to resolve in the worktree;
 the second pass then runs on the stopped merge.

#### What can still end a merge with a nonzero status

Each with its cause:

- A conflict that no resolution settles (exit 1 from native Git, or `merge-isolation/merge-conflict`, exit 2).
- Unsaved work in the worktree that the merge would overwrite: `merge-isolation/paths-in-use` (exit 2), or Git's own refusal
   with a clean index; an untracked file in the way (`merge-isolation/not-applied`).
- A lock that stays held for the whole wait limit: the merge stops, changes nothing and leaves the lock.
- A branch that moved in twenty rounds in a row: the merge stops and changes nothing; run it again.
- A form the wrapper does not complete and native Git runs unchanged: `--squash`, `--no-commit`, `-s`, `-X`, more than one
   branch, `--abort`, `--continue`, `--ff-only`, abbreviated options, an editor, `merge.autostash`,
   `merge.verifySignatures`, `branch.<name>.mergeOptions`.
  These keep native Git's failures (a held lock is waited for first, but the stash of a native merge commit and the late ref
   update are native Git's).
- A merge or sequencer operation already in progress (native Git reports it).
- A signer that fails when the merge is signed (nothing is changed).
- A repository or index the wrapper cannot read (the failure names the file).
- A `git pull` that needs a merge and is not read exactly: it runs its merge inside Git, out of the wrapper's reach.

### Word-level second merge pass

Git's line merge calls two changes that touch a conflict: a line you edited and lines the other side inserted right after it,
 for example, in a paragraph that is hard-wrapped one sentence to a line.
When a `git merge`, `git rebase`, `git cherry-pick`, `git revert` or `git pull` stops on such a conflict, the wrapper re-merges only the
 conflicted parts word by word (the way Jujutsu does with `merge.hunk-level = "word"`),
 and accepts a file only when every part of it resolves.
A file that resolves is written to the worktree and staged as resolved.
When every conflicted file resolves, the operation is finished the way Git finishes it after a clean run (through the wrapper's
 own command path, so the commit policies apply): a merge commits with its prepared message, a cherry-pick or revert commits and
 continues, a rebase continues, and the exit status is the finishing command's.
`--no-commit` and `--squash` stop as they would after a clean run, with Git's line "Automatic merge went well; stopped before
 committing as requested" and status 0, and `--edit` opens the editor.
`git pull` is covered, both its merge and its rebase; `--autostash`, `merge.autoStash`, `rebase.autoStash` and `branch.<name>.mergeOptions` are applied
 as Git applies them; a command with an option the wrapper does not read exactly is left stopped with the files staged and
 Git's status.
What Git printed when it stopped (`CONFLICT` lines) is already out and stays.
An autostash (`--autostash`, `merge.autoStash`, or a pull's) is applied by the commit that ends the finished merge,
 as in Git.
When the stash conflicts with the merged files, the merge commit is still made, the stash is kept in your repository as
 `stash@{0}: autostash`, the files it conflicted in are unmerged in your index with the markers in the worktree,
 and the exit status is the commit's, as after Git's own apply.
The same holds when you conclude such a merge yourself with `git commit`,
 and when you have edited a file the stash touches
 since the merge stopped (Git's apply then refuses and keeps the whole stash).
Standard error lists every part that was merged, with the file and the lines in the base, your side and the other side,
 and the text it became:

```text
cli-git: word-level merge: notes.md: part 1 of 1: base line 3, ours line 3, theirs lines 3 to 4 became "Flags are optional, see below.\nExit code is zero on success.\n"
```

The journal gets one entry per merged file (`journalctl --identifier=cli-git`), with the fields
 `CLI_GIT_WORD_MERGE_FILE`, `CLI_GIT_WORD_MERGE_OPERATION` and `CLI_GIT_WORD_MERGE_PARTS`.
Only conflicts the command itself created are touched; a file you are resolving by hand is never replaced.
A file that does not resolve stays exactly as Git left it.
That includes two sides that append different text at the same place, two different edits of the same word,
 a file with a NUL byte in its first 8000 bytes (Git's own test), a file over 8 MiB, a path added on both sides or deleted on one,
 a link, a path whose mode differs between the sides, and a path whose `merge` attribute names a driver, `binary` or `-merge`.

The result can be text neither side wrote: two edits of different arguments of one call become one call with both edits.
Nothing either side added or kept is dropped, and the parts are listed so you can check them.
The wrapper's own merges from objects (the merge of a staged-index checkout above, the merge of an auto-push that lost a race,
 and the replay of a commit another commit beat to the branch) use the same pass and publish or commit a merge only when every
 conflicted file resolves.
They read no worktree, so they read the `.gitattributes` of the branch tip (`GIT_ATTR_SOURCE`) unless you named an attribute tree yourself
 (`--attr-source`, `GIT_ATTR_SOURCE` or `attr.tree`), and the `union` line for `doc/handover/*.md` applies in them too.
Design and measurements: `doc/planning/cli-git-merge-auto-resolution.md`; the rules: `SPEC.md`, "Word-level second merge pass".

### What can still end a commit with a nonzero status

The owner's rule (2026-11-03): "Commits should never fail." A named-path commit beside another session's unfinished merge or
stopped cherry-pick lands as an ordinary commit and the state is carried on (see `SPEC.md`, "Merges and commits never fail").
Each of these still ends a commit with a nonzero status, with its cause; none is waited out or retried on the moved head yet:

- A Git step the client starts shows no sign of work for its quiet window (`child_class.rs`: 60 seconds for a quick
  query step,
   300 seconds for a bulk step; both unmeasured), and is quiet again when run a second time. A step that works is
   never ended
   for being slow, and a step that may not be run twice (a reference update) is waited for, with no end of its own: only you end it.
- The commit service does not start: a start the manager reports as under way is waited for until `START_CEILING` (60
  seconds),
   a start it reports failed ends the wait at once ("commit service did not start"),
   and a unit it reports inactive is asked
   to start after `START_WAIT`.
- The service does not answer within `ANSWER_CEILING` (20 minutes, heartbeats or not):
  the client withdraws the request, so it
   never lands later by itself, and ends with "This commit waited N minutes for the commit service and was withdrawn";
   if the
   commit landed at that moment the command reports success.
   Five seconds without a word ask the service again with the same
   commit, and a service that does not answer then ends the command with "The commit service stopped answering".
- The branch keeps changing: `LANDING_ATTEMPTS` (3, `service_land.rs`) landings lost to other commits end with
   `BranchKeepsChanging`, and `REBUILD_ATTEMPTS` (`service_commit.rs`) builds by Git on a moved branch end the commit.
- A commit that concludes a merge, an amend or a revert cannot replay onto a branch that moved (`concurrent-commit/head-moved`).
- `index.lock` is held by a program that does not let go within `LOCK_CEILING` (10 minutes),
  or was left by a dead owner (Git's
   lock PID file names a process that is gone; a diagnostic names it at once). The commit is withdrawn,
   kept on a side ref named
   in the message (`refs/cli-git/kept/<batch>`), your staged changes stay staged, and nothing lands later by itself;
   run the
   commit again.
- The content a commit captured was pruned from the object store before it landed (`ClientFailure::ContentPruned`).
- A policy finding at error severity, an empty message or a failing signer. A named path in conflict is no longer one of these: it is committed with the markers it holds and a warning that names it (owner, 2026-11-03: "Committing a conflicted path is fine. Warn and commit anyway, even if conflict markers will be committed."). A plain `git commit` with no paths while files are still unmerged finishes the merge the same way, with the markers in those files and the same warning for each (owner, 2026-11-03: "Warn and finish the merge (Recommended)"); Git's "Committing is not possible because you have unmerged files" no longer ends it. `git commit -a` over unmerged files commits their markers and warns for each file in the same words.

### 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,
   commit 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",
    "mono/catalog-specifiers": "error",
    "security/forbidden-strings": ["error", { "builtinRules": true, "rulesFile": ["forbidden-strings.append.txt", { "path": "forbidden-strings.append.local.txt", "optional": true }] }]
  }
}
```

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 five run only where the file lists them.
Listing one with a severity is the only way to turn it on,
 and a configuration that does not name a policy behaves exactly as it did before the policy shipped.

- `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.
  A file whose Git `text` attribute is unset (`-text`, which `binary` includes) keeps its exact bytes,
  so a package declares its exact-byte inputs, such as fuzz seeds, with `seed/** -text` in its own `.gitattributes`.
  The attribute is read from the commit or index being checked, never from the worktree.
  Files below `dist/final/node/` or `bundle/node/` are also exempt, as generated output.
- `markdown/autofix`:
   default `warn`.
  Runs the linked `monochromatic-lint` fix engine over staged Markdown inside the commit, in-process on a guarded thread.
  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.
- `mono/catalog-specifiers`:
   default `error`,
   no options.
  Every dependency in a tracked `package.json` is `catalog:`,
   `catalog:<name>` or exactly `workspace:*`
   (`peerDependencies` take `*`,
   `catalog:` or `catalog:<name>`).
  The rule belongs to the `monochromatic-catalog-specifier` crate,
   whose README is its contract;
   the policy hands it each candidate `package.json`'s bytes,
   never the worktree's,
   and reports its findings.
  A tracked `package.json5`,
   `package.yaml` or `package.yml` is a finding too,
   reported by name without reading it.
  The policy checks syntax only:
   it looks up no catalog entry and does not read `pnpm-workspace.yaml`.
  It runs when a commit is made,
   when `git add` stages,
   after a landed commit,
   before a push,
   and for `check`;
   it proposes no correction,
   so `fix` skips it.
  The finding codes are `unsupported-manifest-format`,
   `invalid-manifest`,
   `malformed-section`,
   `duplicate-key`,
   `non-string-specifier` and `disallowed-specifier`.
  A `warn` lets a literal version into the commit,
   so it earns the unsafe-`warn` notice.
  This repository's `cli-git.config.jsonc` does not list it yet,
   so the policy is off here.
- `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, or an array of them).
  The rules files are `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, unless it is marked optional.
  Several files are read in order and scanned as one rule set,
  and every one of them is left out of the scan, as the single rules file always was.

The `rulesFile` option takes either form:

```jsonc
// One required file, the form that existed before lists.
{ "rulesFile": "forbidden-strings.append.txt" }
// Several files; a string is a required file, an object may mark one optional.
{ "rulesFile": [
  "forbidden-strings.append.txt",
  { "path": "forbidden-strings.append.local.txt", "optional": true }
] }
```

An optional file that is absent is skipped and recorded in the debug log.
A configured string is one path exactly as written, so a comma or a `?` in it needs no care;
an array never needs the variable's list syntax.
An empty array, an element that is neither a string nor an object with `path`, an object key other than `path` and
`optional`, and a path named twice are configuration failures.
Every path is checked the same way: relative to the repository top level, no `..`, no backslash.
`FORBIDDEN_STRINGS_RULES` uses the scanner's comma-separated list (see
`package/forbidden-strings/cli/README.md`, "Rules list"), where a trailing `?` marks an optional entry;
a value that is not valid UTF-8 is one required path.
Because the variable's entries are resolved against the repository top level too,
the same `{{config_root}}`-anchored value serves the scanner and cli-git.
An invalid variable fails the policy only when a candidate is scanned, like a missing file.
The new-worktree copy takes every configured file that Git ignores and the main checkout has.

#### Content already in history is not scanned again

`security/forbidden-strings` reads the bytes of a candidate
only when no parent commit's tree already holds that exact blob.
A pure rename, a copy of an existing file, and a merge that brings a file only one parent has therefore pass,
because a blob's object name is the hash of its bytes and the bytes are already in history.
What counts as a parent depends on the command:

- `git add`, and a commit being made: the commit the new commit is based on (the recorded base, else `HEAD`).
- The post-commit pass over a landed commit: every parent of that commit.
- The pre-push pass: every parent of a pushed commit that is not itself being pushed,
   so a parent that is part of the push vouches for nothing.
   A push that includes a tip with no known published history skips nothing.
- `git cli-git check`, a root commit and an unborn branch have no parent tree, so everything is scanned.

The comparison is by exact blob object name per path, never by path name or by similarity,
so one changed byte makes the file new and it is scanned in full.
The pathname of a skipped file is still scanned, because a rename gives the content a new name.
A file that a fix corrected during the same invocation is never skipped, because its bytes are no longer its object's.
If the parent trees cannot be listed, every candidate is scanned in full and a warning says why.

Each skipped file is recorded on standard error as a debug line when `MONOCHROMATIC_VERBOSE=true`:
the masked pathname, the blob's object name, and the commit whose tree holds it.

Tradeoffs.
Content that reached a parent through an earlier bypass, or that sits at a path the scanner never reads
(the rules file and the scanner's own rule sources), stays unscanned wherever it is copied or moved to afterwards.
That is also what lets the scanner's own package move to a new directory.
A rule added to the scanner after a file entered history is applied to that file again only when its bytes change.

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`.

### Discarded copies

- `discardedCopies.maxAgeDays`:
   positive integer,
   default `30`.
  Saves of conflicted files older than this many days are removed when a new save is written.
- `discardedCopies.maxSize`:
   a number of bytes above zero or a string such as `"256m"`
   (`k`, `m` and `g`, powers of 1024, as Git's integer settings),
   default `"1g"`.
  While the folder holds more than this, the oldest saves are removed.

Neither limit reaches a save younger than 24 hours.
Zero, a negative or fractional number, and text that is not a size are configuration failures that name the key.
See "Discarding commands" for the pass.

### Concurrency

- `hooks.concurrentCommits`:
   boolean.
  Inert:
  it tuned the hook lock,
  and the wrapper runs no external hook,
  so there is no lock.
  Older files that set it still load; the value is checked and ignored, and `git cli-git doctor` says so.
- `indexLock.unprovenOwnerTimeoutMs`:
   non-negative integer,
   default `1000`.
  Inert:
  it was how long the wrapper backed off for an `index.lock` whose owner is dead or unproven,
   and nothing reads it now (the commit service waits for a foreign lock until `LOCK_CEILING`, proven alive or not).
  Older files that set it still load;
  the value is checked and ignored,
   and `git cli-git doctor` names the key and asks for it to be removed.
- `landing.reserveAfterLostRaces`:
   positive integer,
   default `1`.
  Inert:
  it was how many lost landing races passed before a commit asked for the landing reservation,
   and nothing reads it now (the commit service lands in arrival order).
  Older files that set it still load;
  the value is checked and ignored,
   and `git cli-git doctor` names the key and asks for it to be removed.

Concurrent commits cannot be turned off.

### Waiting and time limits

Nothing the wrapper waits for waits for ever (owner, 2026-11-03: "nothing should ever wait forever or indefinitely"),
and the
wrapper never waits without saying so. Every bound is in `src/native/service/wait_ceilings.rs` with its measurement,
and the list of
every wait is `doc/planning/cli-git-no-unbounded-waits.md`.
A process that works is waited for until its ceiling; a quiet window ends a wait for quiet, a ceiling ends a wait for length, and
the one step that is never ended is a write in place that is not safe to run twice.
A Git step the wrapper starts keeps a ceiling of its class (reads 120 seconds, bulk steps 10 minutes) exactly when it can run twice:
the owner's criterion, decided by cutting each step at every mutating system call, running it again and comparing the end state
(`doc/planning/cli-git-no-unbounded-waits.md`, "The cut experiment"). The branch write (`git update-ref`), `git merge`,
`git reset --hard`, `git checkout`, `git switch`, `git stash store` and `git read-tree -m` came out different and are never ended
(the owner, 2026-11-04: "Never cut a branch write (Recommended)", whose option read "Steps that can safely run twice keep their
ceilings; the branch write is never ended. Your command still returns at its bound with "result not yet known" and the `git log -1`
and side-ref hint. Con: a truly hung write keeps the service busy, and later commits fail at their own 20-minute ceiling with an
error naming it."). A command that started such a step stops waiting at its bound plus 7 seconds (`SHORT_EXTRA_WAIT`, the owner's
"7 s (Recommended)") and exits non-zero saying the result is not known, naming the step and what to check; the step is left running.
The commit service's landing thread waits for it, and a commit that waits behind it is told what the service is stuck in after a
minute (`NEVER_ENDED_STEP_SLOW_AFTER`) and is withdrawn at its own `ANSWER_CEILING` with an error that names the write and the way out
(`journalctl`, and a restart of the service when no program uses the repository). The commit inside the stuck write waits 7 seconds
past its `ANSWER_CEILING` and then reports that it is not known whether it was made, with `git log -1`, `git status` and the write
named; the commit lands when the write returns. A lock holder is waited for until `LOCK_CEILING` (10 minutes),
the commit service's answer until `ANSWER_CEILING` (20 minutes),
a start until `START_CEILING` (60 seconds), the service's hold for a forwarded `git add` until `SERVICE_HOLD_CEILING`
(10 minutes), and a push whose connection is open and silent until `CONNECTED_PUSH_QUIET_WINDOW` (150 seconds, then the
service's retry plan keeps it). At a bound on the commit path the request is withdrawn,
the commit is kept on a side ref,
and the command exits non-zero naming the blocker. A service that goes silent for five seconds is asked again. Where no
inotify watch can be set, the waits poll every 50 ms. These were chosen by the owner's answers of 2026-11-03 and the
coordinator's reading under the delegation, and are open to the owner's veto.
The environment variables below set the limits and the pace of the notices,
each a positive whole number of seconds;
a zero,
negative,
fractional or non-numeric value is ignored.

- `CLI_GIT_CHILD_WAIT_SECONDS`,
   default `60` for a quick query step and `300` for a bulk step.
  How long one Git process that the wrapper starts for itself may show no sign of work before it is ended.
  A step that is slow and works (bytes moved, processor time, a task running or waiting for the disk) is never ended
  for being
   slow, however long it runs.
  The two defaults are not measured: they were wall-clock limits, no log held a quiet gap, and the timing records now
  keep the
   longest quiet gap of every step (`longestQuietMs` and `quietMs` in `cli-git-commit-timing.jsonl`) so a measured
   number can
   replace them.
  A quick query step only reads refs, commits and trees (`rev-parse`, `rev-list`, `ls-tree`, `cat-file`, `diff-tree`,
   `merge-base`, `write-tree`, `update-ref` and the like).
  A bulk step is every other step:
   it moves bytes (`pack-objects`, `index-pack`, `add`, `maintenance run`),
   or compares the index with the worktree (`reset`, `update-index`, `ls-files`, `diff`),
   or is not known to be a query.
  The object reader (`git cat-file --batch`) is a bulk step too, because one request can name a large blob.
  Setting the variable gives every class the same window.
  A step that is safe to run twice and shows no sign of work for the window is ended and run once more;
   if it is quiet again, the command reports which step, its class, the window and how to go on.
  A step that cannot be run twice (a branch write above all) is announced and has no ceiling: the commit service waits for it, and
  any other command stops waiting for it at its bound plus 7 seconds and says the result is not known.
  No hook of yours runs below a step the wrapper starts, and the stop never reaches a process beside the step.
- The tools and checks that are not Git end on the same rule, with windows that are not measured:
   a package-manager tool of the worktree repair after 600 seconds with no sign of work (with its whole process group),
   the Markdown linter after 60 seconds in which its thread shows none,
   and the standards-review call after 10 seconds of silence on its connection (30 seconds for the script's default).
  Each also has a ceiling on its whole run, whatever it shows (owner, 2026-11-04: "Silence plus a ceiling (Recommended)", which
   read "Keep ending on silence, and add a generous overall ceiling per tool, measured from real runs, after which it's ended with
   an error naming the tool. Con: an extremely slow but working run on a huge repository can hit the ceiling."):
   18 minutes for one repair tool (`REPAIR_TOOL_CEILING`, ended with its whole process group, and the line names the tool, what it
   was doing and the command to run by hand), 4 minutes for the Markdown check of one file (`LINT_CEILING`, abandoned as a quiet one
   is), 1 minute for the standards-review call (`REVIEW_CALL_CEILING`) and 2 minutes for the whole review of a commit
   (`REVIEW_CEILING`, with the command `SHADOW_REVIEW_SHA=<commit> mise run shadow-review` that reviews it again).
  Each ceiling is the smallest whole number of minutes that is at least five times the longest run measured on this host
   (`src/native/service/wait_ceilings.rs` has the figures and the method).
  The ceilings are not variables you can set.
- `CLI_GIT_WAIT_NOTICE_SECONDS`,
   default `10`.
  How often a waiting command repeats its notice (see "What a waiting command prints"),
   and how often the commit service repeats the line that names the holder of a foreign `index.lock`.
- `CLI_GIT_PUSH_DEADLINE_SECONDS`,
   default `30`.
  How long the push after a landed commit may show no sign of work and hold no open connection (the stall window)
   before it is stopped.
  The 30 seconds has no measurement or owner decision behind it (the audit keeps it as an open question).
  The push and every process it started are watched: one that moves bytes (on its pipes and files, and on its connected TCP
   sockets), uses processor time, or has a task able to run or waiting for the disk (an `fsync` on a slow volume) is working
   and is not stopped here, however slow; one that does none of these is stopped after this many seconds.
  A run whose connection to the remote is open (the push, the fetch that follows a refused push, and the Git LFS upload)
   is waiting for the remote and is ended only when it shows no sign of work for `CONNECTED_PUSH_QUIET_WINDOW` (150
   seconds,
   unmeasured); the service's retry plan keeps the push and tries it again.
  The owner's answer of 2026-11-03, "Bound it, keep retrying (Recommended)",
  replaced "Wait while connected (Recommended)".
  The wrapper says so once, in a line that starts with `cli-git: git push is waiting for the server`
   (`git fetch` or `git-lfs push` for those runs), and waits until the remote answers or the connection closes.
  "Connected" means that a process of the run holds a TCP socket in state `ESTABLISHED`, read from the kernel.
  A connection that dies without a reset is noticed only when TCP gives up; the wrapper sets no time for it.
  The wrapper adds no low-speed abort of its own to any run (it used to set `GIT_HTTP_LOW_SPEED_LIMIT=1000` and
   `GIT_HTTP_LOW_SPEED_TIME=60`, which ended a silent check after 60 seconds): Git sets neither, and a limit and time you set
   in the environment or in `http.lowSpeedLimit` and `http.lowSpeedTime` still reach Git and still end the wait.
  A push to a local path has no connection to wait on and is stopped after this many seconds, as before.
  The name is kept from the time when this was a fixed deadline, so an existing setting now sets the stall window.
  Where the system cannot count bytes (no `/proc/<pid>/io`, as on macOS),
   it is a fixed deadline for the whole push, as before.
  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 are ended by the same stall window when they show no sign of work;
   a query that works is never ended for being slow.
- `CLI_GIT_PUSH_RETRY_SECONDS`,
   default `10`.
  The pause before the commit service tries a stopped or failed auto-push again on its own.
  A failure that can pass is tried again, with the pause doubled each time up to 600 seconds,
   until the push lands; no new commit is needed, and the service's journal says each time that it will try again
   (cause, number of the try, pause).
  Being offline, a stall, an HTTP 5xx answer of the LFS server and a held lock can pass.
  Three failures stop the retry until the person's next commit, because trying again would get the same answer
   (owner decision, 2026-11-03: "Offline is retried, but auth and server hook declines stop until your next commit."):
   a reported conflict,
   a login the service does not have (Git's "Authentication failed", "terminal prompts disabled", an HTTP 401 or 403, or
   SSH's "Permission denied ("),
   and a hook on the server that declined the push (a `[remote rejected]` line whose reason ends in "hook declined").
  The failure record then starts with plain words that say which one it was, above Git's own words
   (for a decline, the message the server's hook printed).
  A rejection by the server whose reason is none of these is tried again, and the journal names its line.
  The failure record the next command shows says that the service keeps trying, and goes when the push lands.
  The plan survives a restart of the service: it is kept on disk, one small file per branch, in the service instance's own
   journal (`push-retries`), and the service that starts next rebuilds it and tries again with no wrapper command.
  The record the next command shows is not the plan, since showing it removes it.
  A pause that was running when the service stopped starts again from the restart, and the try number is kept.
- There is no ceiling on a push, and no variable sets one:
   no total time ends a push that shows progress or holds an open connection.
  The report of a stopped push names `limit=stall`, the bytes the push moved and the seconds it ran.
  A stopped Git process is first asked to end with `SIGTERM`, which lets Git remove its own `.lock` files,
   and is killed only if it is still running after five seconds.
  A process that was killed, or that could not answer in time, can leave a lock file behind;
   once it is gone the wrapper judges the reference locks of the repository it worked in and removes only those whose holder
   is proven gone (the rules are in the push deadline entry of `SPEC.md`),
   so a later `git fetch` or `git commit` does not fail with "Unable to create ... File exists".
  A lock whose pid file names a process of the stopped tree is removed on the wrapper's own knowledge that it stopped that
   process, also inside a container, where the general proof is not available.
  A lock it cannot prove stale, such as an empty lock without a pid file, is left, because a live Git of another program holds
   exactly such a lock for as long as its transaction lasts; the wrapper says what it found and prints the command that clears it.
  The same stop applies to a Git step that shows no sign of work for `CLI_GIT_CHILD_WAIT_SECONDS`.
- A lock that a Git killed in any other way left (a closed terminal, the out-of-memory killer, a reboot, a release before
  0.5.23) is judged before the wrapper's own fetch and push and before your plain `git fetch` and `git push`.
  It is removed only when its holder is proven gone: the process its pid file names has ended, or its number now belongs to a
  program that is not Git, and no process of a nested PID namespace has that number as its own; and never while a process has
  it open or a running Git is named.
  The age of a lock by the clock is not a proof, because a correction of the clock moves it: a lock left by a reboot is removed
  when it has a pid file, and otherwise left with the command that clears it.
  Git writes no process number into a lock, and a live Git holds its reference locks with no file open, so nothing else can
  prove a lock stale.
  A lock that is not proven is left, and the wrapper prints what it found and the exact `rm` command that clears it
  (`SPEC.md`, "Reference locks left by a Git that was killed").

A command that passes through to Git unchanged,
 and the editor of a commit,
 run as long as they run and receive Ctrl-C like any Git command.

#### What a waiting command prints

Every wait of the wrapper writes to standard error,
so a queued command can be told from a hung one.
A wait that ends within one second prints nothing,
because healthy commits queue behind each other briefly all the time.
After that the first line comes,
and it repeats every `CLI_GIT_WAIT_NOTICE_SECONDS` seconds until the wait ends.
A wait whose holder is already known when it begins (a live `index.lock`,
 another process's push) prints its first line at once.
Each line names what the command waits for,
 the process that holds it when the wrapper can tell,
 how long the wait has lasted and where it gives up.

- An `index.lock` held by a live process, which the commit service waits for before it lands a commit:
   the holder is proven by an open descriptor or Git's PID file and the proof is named,
   and an `index.lock` that nothing proves held is said to be unproven while the wait lasts.
   The first line of a wait adds a hint,
   and the repeats stay short.
- A `git merge` that waits for the index lock of any holder before it changes anything:
   it says it has changed nothing yet, names the lock and the holder with its proof, and repeats.
- The commit service's own hold on the index, which a forwarded `git add` waits for.
- Another process's push of the same branch.
- A long run of `monochromatic-lint` for a Markdown file,
   which names the file and stops waiting for the linter only after 60 seconds in which its thread shows no sign of
   work,
   leaving its thread to end with the command.
   The guard contains a hang and a panic, not a stack overflow or an abort in the linter.
- The object reader and the Git steps of a commit that run long,
   which name the Git command and the process,
   and end it only when it shows no sign of work for the window of its class (`CLI_GIT_CHILD_WAIT_SECONDS`).
  The first notice of a quick query step comes after 5 seconds and that of a bulk step after 10 seconds,
   so a step that only needed a moment prints nothing.

```text
// standard error of a git add that waits for the commit service to finish a commit
cli-git: git add is waiting for the commit service to finish a commit in this repository, 12 seconds so far;
this waits for the service's hold for up to `SERVICE_HOLD_CEILING`, then runs and reports what Git says.
```

## Reading the repository in process

The wrapper starts real Git as rarely as it can.
For read-only questions it reads the repository itself and falls back to Git for anything it does not fully understand.
The readers live in `src/native/repo_read_*.rs`; the design, the measurements and the limits are in
`doc/planning/cli-git-in-process-reads.md`.

What is answered in process, with the standard output, standard error and exit code Git itself would give:

- the repository location every wrapped command asks for (`rev-parse` with the bare, Git directory, common directory,
  top level and prefix options), and the Git paths and object format the commit path asks for;
- `ls-files --stage -z` and `ls-files --unmerged -z`;
- `diff-index --cached` (name-only and raw, with `--diff-filter` and literal pathspecs),
  `diff --cached --name-only -z`, `diff-tree --root -r -m` for a commit with at most one parent,
  `diff-tree -r --name-only -z` between two commits or trees (with or without `--no-renames`) and
  `diff-tree --no-commit-id --name-only -r -z` for a commit with exactly one parent (the landing and the review ask for these;
  only the trees that differ are read), and `show --no-patch --format=%s`;
- `config --list -z --show-scope`, `remote get-url`, `for-each-ref` with the wrapper's format,
  `rev-parse --verify --quiet <ref>^{commit}`, `rev-parse --abbrev-ref --symbolic-full-name @{upstream}`
  and `symbolic-ref --quiet HEAD`;
- `cat-file --batch` fed full object names, in the commit preparation.

Every reader answers only after checking what Git would check, and otherwise leaves the question to Git:
`GIT_*` variables that can move a path, global options other than `-C`, bare repositories, links, other file systems,
reftable, conditional includes, unmerged or intent-to-add entries in a diff,
a `config.worktree` that makes the repository bare, moves the worktree or holds a value the readers cannot prove
Git accepts (with `extensions.worktreeConfig` on, the file is otherwise read and listed under the scope `worktree`),
a file that became a directory, a gitlink, a possible rename, replace refs, shallow or grafted history,
split or sparse indexes, and configuration keys that can change the output.
Differential controls run real Git and the readers over the same command lines
(`src/native/repo_read_*_tests.rs`, `src/native/binary/binary_budget_tests.rs`).

Set `CLI_GIT_IN_PROCESS_READS=off` in the wrapper's environment to start Git for every one of these questions,
for a measurement that compares both paths or to rule the readers out when output differs.

## Operating conditions

This package is one of the few that must keep working under the conditions its host lives in every day,
not only on a quiet machine.
The wrapper, the commit service and their tests are expected to work under these conditions:

- high CPU load from other sessions and builds;
- memory pressure and heavy swap use;
- slow SSD and other I/O trouble;
- a nearly full disk, so a write can fail with no space left in the middle of a commit;
- process and descriptor limits close to exhaustion;
- a process killed at any instruction, which the service's journal exists for;
- a closed terminal or a broken pipe;
- slow fsync and unusual filesystems;
- DNS or mirror trouble, where the wrapper recovers or falls back instead of wedging.

### Latency requirements

Two requirements bound the latency the wrapper adds, and `native:perf` enforces both:

- A forwarded real Git command gains strictly less than `2,000 ms` (`SPEC.md`).
- Every commit made through the wrapper must not take `5,000 ms` or more longer than the same commit made with native Git
  (each commit, not a percentile of them).

No wrapped command is required to finish within a fixed time, and there is no 5-second bound on wrapped commands.
The owner's words (2026-11-01):

> " 5-second bound on wrapped commands" - there is never a 5s bound on wrapped commands.
> The measure is always just commits vs native git should not take 5s longer.

The 5-second `timeout --signal=KILL` of the binary tests is a hang guard of the test harness
(`TIME_BOUND_SECONDS` in `src/native/binary/binary_support.rs`), short so that a mutant that loops fails every affected control
quickly.
It says nothing about how long a correct wrapped command may take.

### What pins each condition

Each condition has permanent controls that start the built executable on real Git fixtures.
The wrapper's side of a commit no longer writes state that outlives the command, so the call-by-call fault sweeps of the
earlier landing (`strace --inject` failing, killing or delaying every write, rename, flush, descriptor and process start of a
commit, and the records they left to recover) retired with that landing.
The commit service's own faults are pinned by the kill points of its journal (`binary_service_tests.rs`: a kill at each point
of a batch, requests sent again, late and older services), the pruned-content controls (`binary_service_pruned_tests.rs`)
and the unit controls of its landing (`service_land_tests.rs`); the call-by-call sweeps are ported to the service below.
What the faults found, and the change for each, is in
[the fault injection note](../../../doc/troubleshooting/git-wrapper-fault-injection-findings.md).

- A closed terminal or a broken pipe: `binary_fault_stream_tests.rs` closes the standard streams, gives them a pipe with no
  reader or a full device, and requires that the status matches the one Git gives when started directly.
- A Git step that stalls: `binary_child_bound_tests.rs` delays the return of every start of a Git step past its class
  bound (`strace --inject`, a `ptrace` of the child, no privilege and no load) and requires that the step is stopped,
  named with its class, and that the repository recovers.
  The control reports a skip and checks nothing on a host without `strace`.
- A process killed at any instruction elsewhere: the worktree-copy and merge flows are killed at their phase markers
  (`binary_merge_isolation_tests.rs`, `binary_worktree_copy_tests.rs`) and, in `binary_fault_trace_tests.rs`, at a spread
  of the Nth `write`, `fsync`, `rename`, `unlink`, directory and process-start calls of each flow (`strace --inject`
  `signal=SIGKILL`, positions counted from an uninjected run, first and last included), followed by the next commands
  and `git fsck`. The commit service is killed the same way at any call of itself and of the Git it starts.
  A staging directory of a killed process stays on purpose and is ignored by every reader.
- A signal to the wrapper alone: `binary_fault_signal_tests.rs` signals the wrapper's own process ID (no `timeout` in front,
  which would forward the signal to its whole group) and checks that a stand-in Git received exactly that signal through
  the wrapper's relay, that the wrapper ends with `128 + signal`, that nothing runs on and that the repository recovers;
  a hangup of the whole group is a separate control. The `SIGQUIT` core dumps of Git seen on the development host came from
  the earlier controls, which signalled `timeout` and so reached Git and the editor directly.
- DNS or mirror trouble: `binary_fault_remote_tests.rs` makes the push after a landed commit and an explicit push meet
  a refused connection, a name under `.invalid`, a server that hangs up, a server that never answers (stopped at
  `CLI_GIT_PUSH_DEADLINE_SECONDS`) and a `git-lfs` that never exits, and requires that the commit stays, the failure is
  worded, no lock is held and no process the push started is left running; `binary_push_environment_tests.rs`,
  `binary_lfs_pre_push_tests.rs` and `binary_lfs_server_tests.rs` cover the stalled push and the failing upload.
  The push is the service's work after the command returned, so each control waits for the work records to settle and reads
  the failure from the notice the next command shows.
- Process and descriptor limits, memory pressure and a starved processor: `binary_fault_limits_tests.rs` sets
  `RLIMIT_NOFILE`, `RLIMIT_NPROC` and `RLIMIT_AS` (`setrlimit` between `fork` and `exec`, no privilege) on the wrapper and on
  the commit service, one side at a time, sweeps the descriptor and address-space limits from the smallest at which the
  program starts to the smallest at which a commit lands (both found by running, so a new build moves them), lowers and raises
  the service's `RLIMIT_NPROC` in place with `prlimit`, and runs a commit with the wrapper, the service and Git on one
  processor. After each, the pressure ends, the next ordinary command succeeds, a further commit lands and nothing is left.
  A failed allocation ends a Rust program with an abort; the controls accept that end and require the recovery.
- Slow storage, slow fsync, a full or failing disk: `binary_fault_trace_tests.rs` runs the service under `strace --inject`
  with every flush returning late (100 ms), with `ENOSPC` and with `EIO` from the Nth storage call on (`write`, `fsync`,
  `rename`), and requires the recovery after each. The wrapper's own `fsync` and writes are not swept because a commit no
  longer writes state that outlives the command.
- High CPU load, memory pressure and slow I/O in the test harness: the 5-second hang guard, which loaded hosts have tripped,
  and the byte copy of the wrapper that keeps a cold start inside it (the troubleshooting note below).

What no control injects, and why:

- Swap and real memory pressure: the kernel's reclaim needs a cgroup the test does not own; `RLIMIT_AS` stands in for the
  allocation failures, and the gate container's `--memory` cap bounds the whole run.
- A real full filesystem, real slow storage, and unusual filesystems (FUSE, NFS, a loop mount): mounting needs a privilege
  the gate does not give. `strace --inject` stands in for the errors and delays, and `binary_subvolume_tests.rs` and
  `fs_kind_tests.rs` cover btrfs and the filesystem classification where the host offers them. The gate runs on an
  overlay filesystem.
- High CPU load: a synthetic load is banned. The container gate's `--cpus` cap and the single-processor control stand in.
- `RLIMIT_NPROC` for a user who may exceed it (a privileged run) is exempt from the limit; the two process-limit controls say
  so and check nothing there.

Known gaps: an editor that never exits is not judged by `CLI_GIT_CHILD_WAIT_SECONDS` (it runs as long as it runs,
as documented, and is ended by the signals above); the removal of a name (`unlink`) is not swept with failing errors,
because a lock file left by a failed removal needs a person with native Git as with the wrapper; a step stopped at its
bound ends with the processes it started, found in `/proc`, so only Linux ends them.

Tests therefore never wait for a quiet host.
The 5-second hang guard on one wrapped command in the binary tests is deliberate,
and is not a requirement on the wrapper;
its measurements and the evidence needed to change it are in
[the troubleshooting note](../../../doc/troubleshooting/native-binary-tests-hit-the-shared-bound-under-host-load.md).

## 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.
A commit keeps a scratch directory `cli-git-service-<id>` in the worktree's Git directory while it is made;
 the commit service removes it when the command ends, however it ends.
The service's after-commit work records and the notices of a failed backup push are kept under `cli-git/`.
The timing record of each wrapped commit is appended to `cli-git-commit-timing.jsonl` (see "Splitting a slow commit").
A directory `cli-git-transactions` in the worktree's Git directory belongs to an earlier release:
 this release no longer uses or reads it.
 A command that finds commit records in it prints a note on standard error,
 names the directory,
 gives the exact `rm --recursive --force -- '<directory>'` command that removes it (with the path quoted for the shell),
 and goes on.
 Look at `git log -1` and `git status` to see whether that commit was made,
 then run the command.

A merge in progress keeps a one-line record in `cli-git-merges` in the worktree's Git directory,
 from before Git starts updating the checkout until the branch has moved.
Do not remove it by hand unless a command says so.

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.

A worktree added from the main checkout receives the main checkout's ignored state minus what a new worktree does not need.
Left out by directory name, wherever the name sits below the main checkout:
 a directory that holds a `.git` file or directory (another checkout, including every registered worktree);
 the caches `.pnpm-store`, `.gradle` and `.kotlin`;
 a `.cache` directory other than the one in the repository root (the root `.cache` holds the tool launchers and generated
 inputs that tasks read, so it is copied);
 `incremental` and `zig-global-cache` below a `target` directory;
 and the records of earlier runs, `.monochromatic` below a `node_modules` directory, `target/verification`, `mutants.out`,
 `mutants.out.old`, `playwright-report`, `test-results` and `.serena/cache`.
The run records follow the constant `COPY_RUN_RECORDS` in `src/native/worktree_copying/worktree_copy_prune.rs`, which is `false`.
A linked or bare source copies everything it ignores except its nested registered worktrees.
An ignored file or directory that the user cannot read, such as a log written by `root`, is left out of any copy instead of
 failing it, and one stderr line says how many and which first paths: `cli-git: left out 2 unreadable ignored entries from
 "/path/to/source": "a/b", "c"`.
The copy clones files with a copy-on-write request, in parallel on a large tree, and trusts the clone: it proves a cloned
 source file unchanged by its size, modification time, change time and inode instead of reading both files again.
A source file written to during the copy still fails the copy, and a file the filesystem would not clone is compared byte
 for byte.

## 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`.
- Every process a unit test starts dies with the test thread, however the test ends:
  start helpers with `spawn_owned` in `src/native/testing/test_support.rs`, which gives the helper a process group of its own
   (so what it starts dies with it) and kills the group when the thread's fixtures are removed,
   the moment a failing or panicking test unwinds (`a_loop_started_by_a_panicking_test_is_gone_when_the_test_ends`).
  A shell loop that a failing push-progress test never ended ran for 87 minutes on 2026-11-03 before this.
- `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 pacman's current Git and no network.
  Set `GIT_POLICY_NATIVE_IMAGE_TAG=<name>` to keep the images of parallel runs apart.
  The test run uses `cargo nextest run --no-fail-fast`, so every test runs even after a failure and one flaky test
  never hides the rest; the exit status is still non-zero, and nextest prints its summary and the list of failed tests
  last.
  The image build is layered so that a rebuild reuses everything the change did not reach, with no network and
  `--locked` throughout (`bin/native-layered-build.ts`, `bin/native-test-containerfile.ts`):
  - The dependency layer compiles every registry crate from the manifests, the lock files, the vendored sources and
    empty stand-in source files, so its cache key holds no source file.
    Only a change to a manifest or a lock file rebuilds it, which includes a version bump of the crate or of a path
    crate.
  - The path-crate layer copies the JSONC crate and the scanner with its engine and compiles them (the scanner's
    build script precompiles the embedded baseline), so it is rebuilt only when one of them changes.
  - The subject layer copies the wrapper's `src/native` and `README.md` (the tests include it) and compiles only the
    wrapper, so a test sidecar edit or a source edit recompiles one crate.
  - The linter stage follows the same split: its registry dependencies compile from stand-ins, then its own sources.
  - Each real-source layer touches the copied files in the same step, because Cargo compares file times with the
    stand-in build and would otherwise treat the stand-in output as current.
  - Build cache mounts (`RUN --mount=type=cache`) are not used: they keep `target/` outside the image, but the test
    run, the planted controls and the mutation campaigns reuse the image's own `target/`, so the compiled output has to
    stay in the image.
  - The image build keeps the same CPU limit as the run.
    In paired builds at the same time, a cold dependency layer compiled about 1.7 to 1.9 times faster with
    `--cpus 4` than with the default (47 s and 58 s against 23 s and 31 s) and the path-crate layer about 1.6 times
    faster, while the wrapper-only layer that a test or source edit rebuilds did not change (about 12 s either way).
    A cold build happens only after a manifest or lock file change, so the default is unchanged;
    pass `--cpus 4` after the `--` for that case (the test concurrency follows it too).
  - Every build step costs a fixed amount on a busy host (measured as tens of seconds, up to minutes, for a step that
    does no work), so the Containerfile keeps its step count low and puts the steps that rarely change first.
- `native:perf` builds the release executable and measures the latency it adds to forwarded Git commands and to
  commits (explicit-path and service, unsigned and SSH-signed),
  with paired direct-Git samples in disposable repositories.
  It reports the median, p95 and median absolute deviation of both sides and the added delta,
  and for each commit the median, p95 and maximum of the paired deltas (the wrapped time minus the direct time of the
  same round).
  It fails when a forwarded command's added p95 reaches the 2,000 ms bound,
  when any commit's paired delta reaches the 5,000 ms bound (the owner's measure, see "Latency requirements"),
  or when any sample fails.
  `-- --output <file>` stores the raw samples (baselines live in `perf/`; `schemaVersion` 2 adds the paired deltas and
  names each scenario's `kind`).
  The judging lives in `bin/native-perf-judge.ts`, and `bin/native-perf-judge.unit.test.ts` runs it on the recorded
  baseline, edited to break each bound.
  Options for measuring a commit at the size the person commits at
  (`doc/planning/cli-git-commit-latency-under-disk-pressure.md`):
  `--shape <repository>` makes every fixture a reflink copy of a prepared repository without remotes (a clone of the main
  checkout, for example), refreshes its index and gives it the tracked file `README.md` to edit;
  `--hooks-from <git directory>` copies that repository's policy hooks, `--tool <path>` puts an executable such as `git-lfs`
  on the fixtures' PATH, and `--only <scenario,...>` measures the named scenarios.
  `--cold` drops the pack, index and packed-refs pages of the fixtures from the page cache before every sample on both sides,
  and `--trace-directory <dir>` leaves an `strace` record of each sample and of each commit service for counting syncs,
  bytes and process starts.
  `--pause <ms>` waits after each sample, so the work a commit leaves running after its answer is over before the next one.
  With `--output`, the report also holds the wrapped commits' timing records and the service's `answered` lines
  (`evidence`), which split a slow sample into its phases.
- `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"`.

### Git's own fsmonitor

Git's file-system monitor is off, by the owner's decision of 2026-11-03:

- "If git's fsmonitor is fundamentally broken and cannot be easily or meaningfully worked around,
  disable git's fsmonitor globally entirely and make doctor say so."

Why: Git 2.56.0's Linux daemon registers an inotify watch for every directory before it starts answering
(`compat/fsmonitor/fsm-listen-linux.c`, then `ipc_server_start_async`),
and a Git process that finds no listening daemon starts one and waits for it for up to `fsmonitor.startTimeout`
(60 s by default; `builtin/fsmonitor--daemon.c`).
On the owner's main checkout, where linked worktrees live inside the checkout, one daemon watched 2,269,377 directories
and spent 117.7 s of CPU and 734 MB to start, and 261 daemons held 4,591,335 watches in all.
A commit of 7.7 s spent 6.5 s with the wrapper's Git child waiting for `git fsmonitor--daemon start`,
while a running daemon saved about 7 ms per `git status` (16 ms off, 9 ms on, five alternating runs each).
The measurements and the cause are in `doc/troubleshooting/git-fsmonitor-daemon-start-wait.md`.

What the wrapper does now:

- It still never passes `-c core.fsmonitor=...` to any Git child, never writes `core.fsmonitor` in any repository,
  and never starts, stops or kills `git fsmonitor--daemon`.
  The setting is the person's configuration, and it is off there: the person's global `~/.gitconfig` holds
  `core.fsmonitor = false`.
- `git cli-git doctor` says so on standard error.
  When `core.fsmonitor` is false or unset it prints one line that Git's fsmonitor is off by the owner's decision of
  2026-11-03, with the file and scope of the entry that decided (or "not set"), the reason in one sentence,
  and the path of the document above.
  When the last entry of `core.fsmonitor` in any scope turns it on (true, a true word, a number other than zero,
  a key without a value, or a hook path) it prints a finding that names the file and the scope and the command that
  turns it off for that scope, such as `git config --global core.fsmonitor false`.
  A value from `-c` or the environment has no file, so the finding says to remove it there.
  Doctor reports and never writes configuration, and its exit status does not depend on this line.
  Doctor reads one more configuration listing with `--show-origin` for this, because the snapshot the commit path
  reads names scopes but not files.

The owner's earlier words, kept as history; each was superseded on 2026-11-03 by the decision above:

- "\"To stop the fsmonitor program\" - we don't need to."
- (2026-11-02) "Why are we messing with git's fsmonitor? I already told you it's fine for git to run it."
- (2026-11-03) "Why are some of your agents still messing with fsmonitor? I've said it at least twice that we
  shouldn't mess with git's own fsmonitor s."
- (2026-11-03) ".git/config overriding it to false is wrong."

Before that decision the person's `~/.gitconfig` set `core.fsmonitor = true`.
A main checkout's `.git/config` had held `core.fsmonitor=false` until 2026-11-03, written by no code in this repository;
it was removed at the owner's word.
Tests that reproduce `core.fsmonitor = true` inside their own temporary repositories stay:
they check that the wrapper behaves as Git does under that setting
(`repo_read_config_typed_tests.rs`, `reader_fsmonitor_tests.rs`, `binary_budget_tests.rs`),
and they never touch a real repository's monitor.
The controls of the doctor line are in `doctor_fsmonitor_tests.rs`, with an injected global file.
`SPEC.md` (search "core.fsmonitor") records the decision for each code path.

### No `--quiet` on the wrapper's own Git runs

No Git run the wrapper or the service starts carries `--quiet` or `-q` where the option only hides messages or progress.
The owner's words:

- (2026-11-03) "No the wrapper should never append --quiet. Letting native git emit whatever it wants to is expected."
- (2026-11-03) "Drop it everywhere (Recommended)", about the wrapper's own internal runs: maintenance, the auto-push fetch,
  fast-forward merges, reset, clean and init.

So no other output-hiding option or redirect stands in for it either.
A `--quiet` stays on a run whose documented effect is the exit status or what is computed (`rev-parse --verify --quiet`,
`symbolic-ref --quiet`, `diff-index --quiet`, `diff-files --quiet`; read in the Git 2.56.0 sources, listed in
`doc/planning/cli-git-slow-server-and-inner-quiet.md`).
The words of a run that said something are kept in the journal (`src/native/worktree/inner_git_words.rs`): the service writes one
`info` entry in its log, and the wrapper writes one client entry (`journalctl --identifier=cli-git`), both with the field
`CLI_GIT_INNER_COMMAND`.
A test that starts such a run records its arguments with `recording_git.rs` and checks `quiet_starts`.
`git reset` without `-q` lists the tracked paths whose files differ from the index, so the service's journal now names
the paths of other uncommitted work for every landing.

### Container base images

Every container image of the verification tasks starts from the newest CachyOS image the host CPU can run,
on its `latest` tag (`doc/decision/container-base-distro.md` at the repository top level).
The tag is never pinned to a version or a digest, so an upstream CachyOS update that breaks a task is a bug to fix at once.
Two derived base images carry what the network-free image builds need:

- `native-test-base.Containerfile` builds `localhost/git-policy-native-test-base`:
  CachyOS, the distribution's Rust (Cargo and Clippy) and build tools, and the tools the tests start: Git (at
  `/usr/bin/git`, where the tests expect the real Git), Node, pnpm, `strace` and cargo-nextest.
  The gate image and the mutation images build on it.
- `e2e/concurrent-commits.Containerfile` builds `localhost/cli-git-concurrent-e2e`: CachyOS with the distribution's Git
  and Node and `ssh-keygen`.

Every tool in these two images is the distribution's current pacman package, with no version pin, no source build and no
downloaded archive (owner decisions of 2026-11-04: "We don't need to pin git because my current system is already using
git latest.", "We don't need to pin Node too.", and "Pacman for everything it has").
That is an exception to the repository rule that containers install locked tools from `toolchain.lock.json` rows, for
these test images only.
Pacman checks every package signature (`SigLevel = Required DatabaseOptional` in the CachyOS image).
`mise run //package/git-policy/cli:test:git-image` prints the release of each tool the base image got; the wrapper's
`MINIMUM_GIT_VERSION` follows the Git release printed there (an older Git draws a warning and is not refused).

Each run of `native:test:container`, `test:git-image`, `test:e2e:concurrent:image` and
`test:e2e:concurrent:native:image` first pulls the `latest` tag with `podman pull --policy=newer`
(one small request when nothing changed; a registry that cannot be reached is reported and the copy the host holds is used).
The derived base is tagged `<first digits of the pulled image ID>-<first digits of the Containerfile hash>`,
so a new CachyOS image or an edited Containerfile builds a new base (with network, once) and every run after that finds it.
The gate image then builds from the base's ID with no network, exactly as before, and its layer cache is keyed on that ID.
`bin/native-test-base.ts` holds the pull, the tag and the build.
The resolver that picks `cachyos-v4`, `cachyos-v3` or `cachyos` is `package/test-harness/core-matrix`;
a host that is not x86-64 stops with an error that names the image, because these images have no Alpine variant.

### Container limits

Every container the verification tasks start is bounded.
The defaults are 2 GiB of memory, 2 CPUs and 512 processes, defined once in `bin/container-limits.ts`.
Each task accepts overrides after the `--` of `mise run`, with these exact option names:

- `--memory <size>` is a whole number with an optional unit `b`, `k`, `m` or `g`, such as `512m` or `2g`.
- `--cpus <count>` is a number from `0.01` with at most two decimals, so `1`, `1.5` and `0.25` are valid.
  The build CPU quota is derived as `cpus * 100000` microseconds per 100000 microsecond period.
- `--pids-limit <count>` is a whole number of processes from `1`.
  It is accepted only where the container has a process cap, and rejected with a message otherwise.

Every container also starts at the lowest disk weight, `--blkio-weight=10` and `--cgroup-conf=io.weight=10`
(`lowestDiskWeight` and `diskWeightArguments` in `bin/container-limits.ts`); no option raises it.
Podman writes the first to `io.bfq.weight`, which the BFQ I/O scheduler reads,
and the second to `io.weight`, which the iocost controller reads, and each leaves the other file at the default weight,
so passing both puts whichever file the host's disk scheduler reads at the lowest value.
`podman build` accepts neither option, so the containers of image builds keep the default weight.

A value is a separate token (`--memory 1g`); the `--memory=1g` form is not read and reaches the task's own option parser,
which rejects it as an unknown argument.
An invalid value stops the task before any container starts.
The message names the option and the accepted forms.
Options repeat with the last one winning, and the number of build jobs (`CARGO_BUILD_JOBS`, `make --jobs`) follows the
whole CPUs of the limit, never below one.

```sh
# Run from the repository top level.
mise run //package/git-policy/cli:native:test:container -- --memory 4g --cpus 4
mise run //package/git-policy/cli:native:mutation:scoped -- --memory 1g --cpus 1 --file src/native/invocation/startup_gate.rs
mise run //package/git-policy/cli:test:e2e:concurrent:native --memory 1g --cpus 1
mise run //package/git-policy/cli:test:git-image -- --memory 4g
```

- `native:test:container` accepts all three options, and so do `native:mutation:scoped` and `test:git-image`.
- `native:mutation` runs the gate and then the mutation step, and mise passes the options to the mutation step only,
  so the gate runs at the defaults.
  Run `native:test:container` with its own options first when both need changing.
- `test:e2e:concurrent:native` takes `--memory` and `--cpus` as task flags and applies them to the run.
  The image build it depends on uses the defaults, and `test:e2e:concurrent:native:image` and
  `test:e2e:concurrent:image` take the same options after the `--`.
  The e2e containers have no process cap either.
- The mutation manifest records the limits it ran with, and the gate and image-build manifests record the container
  argument arrays.
- `native:mutation:scoped` reuses the image of the last gate, whose `CARGO_BUILD_JOBS` was fixed when it was built.
  Run the gate with the same `--cpus` for the job count to match.
- The `package/git-policy/cli.fuzz` campaign uses the defaults from the same module and has no override options yet.

### Who runs a mutation campaign

The session's main agent starts every campaign of this package and waits on it, never a subagent; the owner's reason
is in the launcher's [Who runs a campaign](../../mutation-test/cli-cargo-mutants/README.md#who-runs-a-campaign).

### Resuming a mutation campaign

`native:mutation:scoped` and `native:mutation:parallel` (and so `native:mutation`) are resumable.
A kill, a reboot, an out-of-memory stop or `systemctl --user stop <unit>` loses no scored mutant:
run the same task with the same options on the same gate image and it continues where the campaign stopped, scoring
only mutants that no earlier run scored, whether they were caught, missed, timed out or unviable.
For `native:mutation:parallel`, start the same launcher command again; each file resumes its own campaign.

The campaign's results are in `target/verification/native-mutation-<key>/`,
where the key names the gate image,
the exact options,
the digest of the planted controls and the identity of the code that runs and judges them,
so a changed control table or runner rescores.
While a campaign runs, `attempt-NNN/mutants.out/` there holds the outcomes scored so far in `caught.txt`, `missed.txt`,
`timeout.txt` and `unviable.txt`.
When it ends, `mutants.out/` holds the same four files as the union of every attempt, with `outcomes.json`, `log/`
and `diff/`, and `exit.json` records the status of the whole campaign (`attempt` and `skippedMutants` say how many
earlier mutants the last run left out).
To score everything again, delete that directory; a rebuilt gate image gets a new one by itself.
The mechanism, the choice of cargo-mutants `--iterate`, and the limits are in
`package/mutation-test/cli-cargo-mutants/README.md`.

### What a mutation campaign costs per mutant

Every mutant runs the package's tests, so the cost of a campaign is mutants times the time the tests need
(`doc/planning/mutation-campaign-per-mutant-cost.md` has the measurements).
Two measures cut it; both keep every kill and every result.

The baseline is skipped when the gate already passed on the same image.
`native:test:container` writes `manifest.json` (the full ID of the image it tested) when it starts and `passed.json`
(`tests`, `clippy` and the same `image`) only after the test run and Clippy both exited zero, in
`target/verification/native-<random>/`.
`native:mutation:scoped` resolves the ID of the image it layers cargo-mutants over and runs the unmutated baseline only
when no such directory names that ID with a passed record; the log says which it did (`Baseline: skipped, because
<directory> proves ...` or `Baseline: run, because no gate evidence ...`) and the campaign manifest records the
directory as `baselineProof`.
A rebuilt snapshot has another image ID, so it has no proof until its gate has passed.
The proof is the gate's pass on that image, and only for a campaign whose `--memory` and `--pids-limit` are at least the
ones in the gate manifest's `limits`: with fewer processes than the gate had, the fork failures of 2026-11-01 would come
back as caught mutants, so the log names the gate directory and the limits and the baseline runs.
A manifest that records no readable limits proves nothing.

The planted controls (a full suite run per guard removal,
about 24 minutes per campaign) are skipped by the same idea.
A campaign whose controls were all noticed leaves `planted-controls-passed.json` in its evidence directory
(in its `pass-1` directory for a two-pass campaign),
written to a temporary file,
flushed and renamed so a reader never sees half of it.
The record holds `plantedControls: true`,
the full `image` ID,
`limits` (the container arguments the controls ran with),
the control names in `controls`,
and `controlsSha256`,
a digest of every control's file,
replaced text,
new text and named test.
`native:mutation:scoped` looks through `target/verification/native-mutation-*` before it plants anything,
and skips the controls when a record names the same image ID,
the same controls,
and no more `--memory` or `--pids-limit` than this campaign has.
The log says `Planted controls skipped, because <record> proves ...`
or `Planted controls: run, because no campaign evidence proves ...`.
A rebuilt snapshot has another image ID,
and a control added,
removed or edited has another digest,
so either one runs the controls again.
A record made at larger limits is named in the log and not used.
A control that was not noticed,
or one whose planted source did not compile,
leaves no record.
`--cpus` is recorded but not compared,
because fewer CPUs only slow the suite.
The record also holds `harnessId`,
the `plantedHarnessId` literal in `bin/native-planted-controls-harness.ts`:
the digest of the sources that run and judge the controls
(`bin/native-planted-controls.ts`,
`bin/native-planted-controls-outcome.ts` and `bin/native-verification-process.ts`).
It is part of the loaded code,
not hashed from disk afterwards,
so an edit between loading and writing cannot publish a record for code that never ran,
and it is content-derived,
so two branches that change the judge differently cannot share an identity the way a counter would.
A unit test asserts that the literal equals the digest of the sources and prints the digest to write,
so editing any of them without updating the literal fails the test,
and a record of another identity is not proof.
Limits are not parsed as Podman parses a command line.
The runners write only `--rm`,
`--init`,
`--network=none`,
the two disk-weight arguments (a record made before they existed lacks them and is still read),
`--cpus=` and one `--memory=` and one `--pids-limit=`,
so a recorded argument list with anything else
(a repeated bound,
a split or attached short option,
a cluster,
an option whose value looks like a bound,
a trailing bare option)
was not written by a runner and gives no proof,
whatever it would mean to Podman.
A zero or unlimited value (`--pids-limit=-1`) is no bound a campaign can be compared with either.
The same reading applies to the gate's `passed.json` proof.
Campaigns take no lock for this.
Two campaigns started together over one image,
with no record yet,
both run the controls and each writes its own record;
neither waits for the other,
because waiting would serialize work that is independent.
The cost is that both pay the 24 minutes,
so start one first and the rest after its record exists.
`NATIVE_MUTATION_SKIP_PLANTED=1` still skips without any record
(a person's claim that another campaign ran them).
A resumed campaign is held to the same rule as a new one,
and a changed control table or a changed runner or judge rescores:
the digest of the controls and the runner and judge identity are part of the campaign key,
so a campaign run after either changed is another directory that keeps no scored results.

`native:mutation:parallel` runs the controls beside the file units,
never before them (`bin/native-mutation-parallel.ts`,
`bin/native-planted-parallel.ts`).
Before it starts anything it resolves the image ID of every unit's tag,
and looks for a proving record with the reader above,
at the memory and process limit the units will run with.
With a record,
no extra unit starts,
and each file unit logs the record it found.
Without one,
it starts one more capped unit per distinct image,
named `<unit prefix>-<label>-planted-<first 12 of the ID>`,
through the same slot limiter.
That unit runs only `native:mutation:planted`,
and writes the record into a `native-mutation-planted-*` evidence directory.
The file units start at the same moment and do not wait.
Each skips the controls,
because `NATIVE_MUTATION_PLANTED_UNITS` names the unit for its image,
and logs `Planted controls skipped here, without proof`,
with the unit and the image.
That skip is not a pass:
the results of those campaigns hold only once the controls unit ends with a `planted-controls-passed.json` for the image.
The launcher prints the controls unit,
its log and that rule,
and puts the unit first in the `systemctl` wait hint.
The controls unit's log ends with `Planted controls passed; recorded in <record>`,
or with `Planted controls FAILED over image <ID>`,
followed by the controls that were not noticed,
and the unit then shows as failed.
If systemd refuses the controls unit,
or a tag does not resolve to an image,
the launcher says so,
and the file units of that image plant the controls themselves,
as `native:mutation:scoped` does alone.
`--setenv NATIVE_MUTATION_SKIP_PLANTED=1` after the `--` still skips without proof,
and no controls unit starts.
The controls unit runs the full image ID the launcher inspected (`--image`),
never the tag,
which may be moved while the unit waits for a slot.
The launcher inspects with the environment a unit gets
(the tag,
the `--setenv` entries,
and its own `TMPDIR`,
`PATH` and `HOME` last)
and nothing inherited from its shell but `XDG_RUNTIME_DIR`,
so it looks in the container storage the units will use,
and it refuses a `--setenv` of the image tag variable or of `NATIVE_MUTATION_PLANTED_UNITS`,
which it sets itself,
because an override would run another image than the one inspected.
The controls unit gets the same extra environment as the file units,
with the launcher's own `TMPDIR`,
`PATH` and `HOME` last.
It succeeds only when it published a matching record or found one,
so a record that could not be written fails the unit.
A campaign that deferred keeps its obligations in its manifest as the list `plantedControlsDeferred`.
Each holds the image,
the limits the campaign ran with,
the digest and names of the controls,
the runner and judge identity,
and the controls unit with its systemd invocation ID and its log.
The launcher names the invocation it started in the coordination entry,
so a campaign defers only to that invocation and never to a later unit that took the name.
Whether an obligation is met is asked on demand and never kept:
a matching record exists,
found with the same reader and by the saved keys,
not by the asking process's own table or code,
or it does not.
Finalization and `native:mutation:reconcile` run that lookup and print the record path,
or "no record yet" with the unit,
its invocation and its log.
With no record,
an obligation is pending while that very invocation runs,
and unvalidated once it ended,
after one more look for a record published in between.
Unvalidated fails the campaign's finalization,
so a campaign whose controls failed never passes.
So does a manifest that is unreadable,
holds an incomplete obligation,
is missing after the campaign finished (`exit.json` without `manifest.json`),
or no longer holds an obligation the running process itself decided to keep.
A campaign's key includes the controls digest,
the runner and judge identity,
and the memory and process limit (the memory normalized to bytes),
and `campaign.json` records those key parts in full so that a clash of the shortened key is refused,
so a resumed campaign has the same controls,
runner and limits as the attempt that deferred,
and a proof or a local run in a resume pays that obligation by construction.
An attempt that proves or runs the controls writes a manifest without the field,
and a person's skip does the same.
The two passes of a two-pass campaign share the key and each directory is validated by its own manifest.
A pass that runs the controls itself while the first pass is still waiting for a record must publish its own,
and fails the campaign with the reason when it cannot,
because a debt in another directory is paid only by a record the lookup finds.
A directory in which a container ran
(an `attempt-*` holds `mutants.out`)
and that has no manifest is invalid,
finished or interrupted;
a controls-only directory,
an attempt that never started,
and the aggregate root of a two-pass campaign whose pass 2 never ran have none and are exempt.
The controls unit's name carries a token unique to the launch,
so no other launch's unit can be taken for it.
A file unit that starts after the controls unit is gone and left no record runs the controls itself.
`mise run //package/git-policy/cli:native:mutation:reconcile` looks up the obligation of every campaign under `target/verification` later,
and exits nonzero unless all are validated.

`--passes 2` runs each file's campaign in two passes.
Pass 1 runs only the tests that can reach the mutated file: the tests of its module and of every module that imports
it, directly or through other modules (the closure of importers over the crate's `use` paths under `src/native`), plus
the test targets that refer to one of those modules, and never the binary suite (`native_binary`).
Pass 2 runs the whole suite on the mutants pass 1 missed or timed out, and on those only.
The report in `mutants.out` and the exit status read as one campaign: a mutant pass 2 scored has pass 2's outcome.

```sh
# Run from the repository top level; one file, two passes, measured on its own before a queue is started.
mise run //package/git-policy/cli:native:mutation:parallel -- --passes 2 --timeout 900 m1 repo_read_query.rs
```

`--passes 2` refuses a glob `--file`, no `--file` and a file the library does not declare, instead of quietly running
one pass.
It also refuses to start without the gate proof above: pass 1 runs a filtered baseline at best, so run
`native:test:container` first, at limits no larger than the campaign's.
A campaign directory started with `--passes 2` refuses a run without it, and the other way round; move the directory
away to switch.
The selection is written to the manifest (`passOne`) and to the log.
How the two passes are seeded, composed and resumed is in
`package/mutation-test/cli-cargo-mutants/README.md` under "Two passes".

### Source layout

The crate is one library and two executables,
and its sources live under `src/native/`.
Each folder there is one subsystem,
so a contributor opens a folder for a concern instead of searching a flat list of
prefixed file names.
The small-file rule stays:
a folder holds many small modules,
each with its sidecar test file.

#### How a module is reached

- `lib.rs` is the map.
  It declares each folder (`pub mod command;`) and re-exports the folder's modules with a glob (`pub use command::*;`).
- Each folder's `mod.rs` declares the modules inside it with one documented line each.
- The glob re-export keeps every module at its old path,
  so `crate::command_options` and
  `git_policy_cli::command_options` still resolve.
  The fuzz crate,
  the two executables and the integration tests name modules that way and needed no change.
- A glob re-export loses to an item written in `lib.rs`,
  so no folder may share its name with a module.
- `use super::` in a module that a folder declares directly names the folder,
  not the crate root.
  Write `use crate::module_name` to reach a module of another folder.
- Library test names now start with the folder:
  `command::command_options::tests::name`.
  The integration test roots (`native_binary`,
  `native_candidates`) keep their names.

#### Subsystems

- `invocation/`:
  what one invocation does from arguments to Git:
  the decision,
  real Git resolution,
  forwarding,
  startup,
  management words and the wrapper's own Git version check.
- `config/`:
  reading and validating `cli-git.config.jsonc` and the Git configuration a command is judged by.
- `command/`:
  the option grammar of each Git command the wrapper understands and the rules that rewrite,
  refuse or hint.
- `policy/`:
  the policy engine:
  registry,
  triggers,
  events,
  passes over candidates and the direct fix.
- `markdown/`,
  `dependent_version/`,
  `standards_review/`:
  three policies large enough for a folder each.
- `candidate/`:
  what a commit or push would add,
  read from the index or objects,
  and the scanner run over it.
  Its `candidate_consumer_tests.rs` is the `native_candidates` test root.
- `commit/`,
  `shadow/`,
  `transaction/`:
  the wrapper's commit path,
  the shadow repository it prepares on and replays
  onto,
  and the durable transaction records with their recovery.
- `landing/` and `service/`:
  what the resident commit service does to land a commit,
  and the service program around
  it:
  protocol,
  server,
  client,
  journal,
  provisioning and the unit files in `service/service_units/`.
- `push/`,
  `lfs/`,
  `merge/`,
  `conflict/`:
  pushes and their waits,
  Git LFS,
  merges with the corpus in
  `merge/word_merge_corpus/`,
  and conflict state around commits and checkouts.
- `worktree/`,
  `worktree_copying/`,
  `subvolume/`,
  `repair/`,
  `health/`:
  worktree commands,
  the ignored-state copy,
  btrfs subvolumes and the filesystem primitives under them,
  package-manager repair,
  and the `doctor` report.
- `repo_read/`,
  `index/`,
  `lock/`,
  `process/`,
  `foundation/`:
  reads without a Git process,
  the index file,
  locks and claims,
  facts about processes,
  and shared building blocks that depend on no subsystem.
- `testing/`:
  test-only fixtures shared by many unit tests,
  never part of the release executable.
- `source_guard/`:
  the controls that read the crate's own source text.
- `binary/`:
  the consumer-level controls that start the built executable;
  `binary_tests.rs` is the `native_binary`
  test root.
- At the top:
  `lib.rs`,
  `main.rs` (`cli-git-native`,
  installed as `git`) and `service_main.rs` (`cli-git-service`).

#### Where new files go

- A new module goes in the folder of the subsystem it belongs to.
  Declare it in that folder's `mod.rs` with a documented line,
  never in `lib.rs`.
- Its sidecar test file `<module>_tests.rs` sits beside it and is declared in the module with `#[path]`.
  Test support used by one subsystem stays in that folder under `#[cfg(test)]`;
  support used by many goes in `testing/`.
- A data file that `include_str!` reads sits in a subfolder beside the module that reads it,
  and its path is added to the
  `include` list in `Cargo.toml` when the published crate needs it.
- A new subsystem is a new folder with a `mod.rs`,
  a `pub mod` and `pub use` pair in `lib.rs`,
  and a line in the list
  above.
  Its name must differ from every module name,
  singular where it reads naturally.
- Do not put a module in `lib.rs` or beside it;
  the folders exist so the top level stays short.
- The source scans (`source_guard/`) follow `mod` declarations from the `Cargo.toml` targets,
  so a file that no
  declaration reaches fails them.
- Paths in tooling name the folder,
  for example `src/native/command/command_add.rs`.
  A mutation scope may name subfolders (`--file src/native/command/command_add.rs`);
  pass 1 of a two-pass campaign
  selects tests by top-level folder,
  so its first pass covers more tests than it did when every module was a top-level
  entry.
- Records under `doc/` that predate this layout name the old flat paths.
  `git log --follow -- <new path>` finds a file's history across the move.

#### The `native` directory layer

The sources sit under `src/native/` although the TypeScript implementation that the name told apart from is gone.
This is a layout decision,
picked by the coordinator's agent,
veto open.
Removing the layer needs no binary rename and no behaviour change,
because the targets are named in `Cargo.toml`.

- Keep `src/native/` for now and drop it in a follow-up.
  - Pros:
    no second mass rename while other branches still edit these files;
    nothing else changes paths.
    The shared mutation scope rules (`nativeSourcePrefix`) and their golden scenarios keep their prefix.
  - Cons:
    the redundant segment stays in every path until the follow-up.
- Move to `src/` now (`src/lib.rs`,
  `src/main.rs`,
  `src/service_main.rs` and the folders).
  - Pros:
    the conventional Cargo layout and shorter paths,
    in one step with the folder move.
  - Cons:
    a second rename of every file under review in other branches,
    a change of the prefix in the mutation package
    and its golden scenarios,
    and hundreds of path mentions in other packages and documents.
- Move to `src/` with the executables in `src/bin/`.
  - Pros:
    Cargo finds the executables without `[[bin]]` paths.
  - Cons:
    everything the previous option costs,
    plus the executable entry points move away from `lib.rs`.

Ranking:
keep and drop later > move to `src/` now > `src/` with `src/bin/`,
because the first avoids a second rename
while branches are in flight and loses only a path segment,
the second reaches the same layout as the third at a
smaller cost,
and the third only adds moves that nothing requires.

## License

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

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

## The commit service

A scoped commit (`git commit -m <message> -- <paths>`, with the message, identity, sign-off, trailer, empty-commit,
reused-message, fixup and `-a` options listed in `doc/planning/cli-git-resident-service.md`) is landed by a resident commit
service instead of by the wrapper itself. The service is a separate program, `cli-git-service`, that the operating system
starts on demand; the wrapper never starts it. Commits queue in arrival order, a batch of whatever is queued lands with one
durable write, and a commit that waits says how many commits go first. There is nothing to install and no setting.

- The first commit that needs the service sets up its socket activation itself, and every later one checks it is still
  right: it compares the two installed units with what this release would write and, only when they differ (an upgrade or
  a downgrade moved the service program), rewrites them, reloads the manager, clears failed state and starts the socket.
  When they match it opens no connection to the bus. Setting up writes two unit files into
  `$XDG_RUNTIME_DIR/systemd/user` (nothing is left in the home directory; the next first use after a login does it again)
  and asks the user's service manager, through the session bus, to start the socket. The manager starts the service when
  the first connection arrives. Units that an earlier release wrote into `~/.config/systemd/user` are recognized by their
  content and removed; a file of the same name that something else wrote is never touched, and the commit stops and names it.
- The single service of the releases before one service per worktree is stopped before the first instance starts, wherever
  its unit files are (including `~/.config/systemd/user`); the commit waits for it to be inactive, and fails naming it when
  it will not stop. Limit: going back to one of those releases starts its single service beside the per-worktree instances
  until their idle hours pass; stop `cli-git@*.service` and `cli-git@*.socket` with `systemctl --user stop` before committing
  with the older release (`doc/planning/cli-git-resident-service.md`, "Limit: a downgrade").
- A failed backup push of a linked worktree, and a push the service had not run yet, survive `git worktree remove` and
  `git worktree prune`: the wrapper copies them first, and the next command in any worktree shows them once. The copy is
  kept while the command that took it may still be removing, and until the worktree's checkout is seen intact (Git
  refused) or its Git directory is gone (Git removed it); a record that cannot be copied or kept is printed on standard
  error, and what could be copied stays. Limit: a worktree that Git's own `gc` prunes is not seen by the wrapper, so its
  records are lost with it.
- The service keeps the records of its newest 1024 finished batches in its journal and removes older ones when it starts;
  a request is answered as already landed only while its own client retries it, which is minutes at most.
- The service exits after three hours with nothing to do (Bazel's server has the same default); the next commit starts it
  again. A service started from another release of the wrapper is asked to finish its batch and exit, and the next commit
  reaches the current one; if it does not exit, the commit says so and names `systemctl --user restart cli-git.service`.
- `git cli-git service status` says whether the service answers; it is a diagnostic only.
- When the service cannot be set up (no session bus, no user service manager), the commit stops with a plain message that
  says what to do, and changes nothing: the service is the only way a commit lands, so there is no other path. Every command
  line the service does not build from the command line itself (amend with an editor, an editor, signing, templates, a
  repository whose configuration sets a template, a clean-up mode, signing or trailers) is built by real Git in a scratch
  repository and landed by the service as Git made it.
- The backup push and the automatic upkeep of a commit the service landed run in the service after the command returned.
  A push that fails does not fail the commit; the next commit through the service prints a notice once. The service pushes
  with its own environment, so a variable only your terminal has (an ssh agent address) must also be in the user manager's
  environment.
- Where a worktree holds the tracked file `doc/audit/standards-review-gate-shadow.md`, the service also runs a standards
  review of each commit it landed, after the command returned, and appends one entry to the repository's review log,
  `<common Git directory>/cli-git/standards-review-shadow.md`. That log is untracked and inside the Git directory, so no
  checkout shows it as a change; there is one per repository, shared by every worktree's service, and the service creates it
  when missing.
  A linked worktree's entries go there too,
  never into that worktree's own Git directory (`.git/worktrees/<name>`),
  which Git removes with the worktree. Each entry is written in one append, so entries of concurrent instances never interleave, and no lock is
  taken. The tracked file only switches the review on and is never written by the service. The log has no rotation.
  `SHADOW_REVIEW_LOG` points it at another file; the runbook is `doc/runbook/standards-review-gate.md`.

### Splitting a slow worktree add

The wrapper appends one JSON line per `git worktree add` it serves to `<common Git directory>/cli-git-worktree-add-timing.jsonl`,
 in one append with no flush to disk, beside the commit timing file.
`steps` gives each step's milliseconds and calls (steps do not overlap, and `otherMs` is the rest of `totalMs`),
 `work` gives the time spent on worker threads by name (the Cargo workspaces, the tools' repairs and the source checks run side by
 side, so a name's sum can exceed the step that waited for it), and `notes.route` names the rung that served the add.

```sh
# sh: the newest add of this repository, one line
tail --lines=1 -- "$(git rev-parse --path-format=absolute --git-common-dir)/cli-git-worktree-add-timing.jsonl"
```

### Splitting a slow commit

Two logs exist without anyone having switched anything on,
and neither is ever shown at the terminal.
Together they split the time of a wrapped commit by phase.

The wrapper appends one JSON line per commit to `<common Git directory>/cli-git-commit-timing.jsonl`,
beside
`cli-git-audit.jsonl`,
in one append with no flush to disk.
Every worktree of the repository writes to the one file,
and a worktree's `instance` field tells the lines apart.
The file moves aside to `cli-git-commit-timing.jsonl.1` at one mebibyte, replacing the older one.
At the rate measured on 2026-11-03 (760,605 bytes in about 30 hours) that keeps three to four days of timing.
A failed write never changes the commit's result.
A commit that stops before it reaches the service,
because the machine has no way to start one,
writes no line.
Read the newest lines of the repository you committed in:

```sh
# sh: the five newest commits of this repository, one per line
tail --lines=5 -- "$(git rev-parse --path-format=absolute --git-common-dir)/cli-git-commit-timing.jsonl"
```

The fields,
all durations in whole milliseconds taken from the monotonic clock:

- `startMillis` is the wall-clock start (milliseconds since the Unix epoch),
  and `time` is when the line was written, in the form of every other record of the wrapper.
  `requestId` is the identity of the commit's request,
  the same text as `request="<id>"` on the service's `answered` line and as the journal field `CLI_GIT_REQUEST_ID`,
  so the two records join on a key and not on a time;
  it is null for a commit that never made a request.
  `level` is `warn` for a command that `stopped` and `info` otherwise.
  `instance`,
  `gitDir`,
  `branch`,
  `release`,
  `pid`,
  `outcome` (`landed`,
  `ended` or `stopped`) and `exitCode` say which
  commit it was.
- `totalMs` is the whole command.
  It is the sum of `prepareMs` (policies,
  hooks,
  the private index,
  the tree and the objects;
  `attempts` counts the
  preparations),
  `buildMs` (the message,
  the identities,
  the tree check,
  a commit Git builds,
  the hand-over of the scratch
  directory),
  `provisionMs` (checking and,
  after an upgrade,
  rewriting the socket-activation units),
  `submitMs` (sending the
  request and waiting for the answer),
  `afterLandingMs` (the summary and the files a policy corrected),
  `gateMs` (the
  post-commit gate),
  `handoffMs` (leaving the push and the upkeep for the service) and `otherMs`,
  which is whatever no phase
  covers.
- `submitMs` contains `reachMs`,
  the time to reach a service of this release.
  `connectTries`,
  `refusedConnects` and `startWaitMs` show the manager starting the service,
  `updateRounds` counts the
  retire requests sent to a service of another release,
  `releasesSeen` lists every release that greeted,
  in order,
  and
  `answeredByRelease` is the one that took the request.
- `submitMs` also contains `answerWaitMs`,
  the time from the request being written to the answer:
  the queue,
  another
  program's hold on the index and the landing itself.
  `positions` lists the queue positions the service reported,
  `waitingNotices` counts its notices that it was waiting for
  another program,
  `lockHolderKind` says what the last of them said of that program
  (`proven-alive` for a process proven to hold the lock,
  `unproven` for an owner nobody could prove alive,
  null when there was none),
  `reconnects` counts connections that broke and `sends` counts requests written.
- `handoffMs` is one figure:
  leaving the work records and waking the service,
  which reaches the service a second time.
  It is not split into a reach and a wait.

The service writes to its standard error,
which the service manager keeps in the journal of its unit.
The unit is `cli-git@<instance>.service`,
and `<instance>` is the `instance` field of the commit's line.
Without a line,
it is 32 hexadecimal digits,
the 128-bit FNV-1a hash of the real path of the worktree's own Git directory
(`.git` in the main worktree,
`.git/worktrees/<name>` in a linked one):

```sh
# sh: the unit instance of the worktree you are in
node --input-type=module -e '
import { realpathSync } from "node:fs";
let hash = 0x6c62272e07bb014262b821756295c58dn;
for (const byte of Buffer.from(realpathSync(process.argv[1]))) { hash ^= BigInt(byte); hash = (hash * 0x0000000001000000000000000000013bn) & ((1n << 128n) - 1n); }
console.log(hash.toString(16).padStart(32, "0"));
' "$(git rev-parse --absolute-git-dir)"
```

`systemctl --user list-units --all 'cli-git@*.service'` lists the instances that exist.
Read the unit around the commit's `startMillis`:

```sh
# sh: the service's lines for one worktree on 2026-11-01 between 22:52 and 22:54
journalctl --user --unit="cli-git@<instance>.service" --since="2026-11-01 22:52:00" --until="2026-11-01 22:54:00"
```

Or find the service's entry for one commit by the `requestId` of its timing line:

```sh
# sh: the service's entry for one request, with its fields
journalctl --user --output=json-pretty CLI_GIT_REQUEST_ID=30cbb78e-677f-41ff-b059-87d9f49d3409
# sh: only what failed or did not go as intended
journalctl --user --unit="cli-git@<instance>.service" --priority=warning
```

The service sends each line to the journal's native socket when its standard error is the journal
(the service manager's `JOURNAL_STREAM` names the very stream the process has as standard error).
The journal then dates the line by the moment it was sent, not by the moment it got round to reading it,
which on a loaded machine could be seconds later and out of order with the manager's own lines.
A line carries its priority (`err` for something the service needed and could not do,
`warning` for something that did not go as intended or a request that failed,
`info` for the rest) and fields to filter on:
`CLI_GIT_RELEASE`,
`CLI_GIT_INSTANCE`,
`CLI_GIT_AT_MS` (the time of the event),
and on some lines `CLI_GIT_REQUEST_ID`,
`CLI_GIT_BRANCH`,
`CLI_GIT_OUTCOME`,
`CLI_GIT_BATCH` and `CLI_GIT_OID`.
When the journal is not listening or the machine's socket queue is full,
the line goes to standard error instead,
prefixed with its priority as `<n>` for the stream to read,
and a service run by hand writes plain lines to the terminal.
A value with a newline in it reaches the journal as written (the protocol's length-prefixed form);
the line on standard error is one line.
The write to standard error never waits for room:
a line the stream took only in part is finished before the next one starts,
and a line that finds no room is dropped and counted, and the next entry that gets through says how many
(`CLI_GIT_LOG_DROPPED` on a native entry, a warning line on the stream).
Every line ends with `at_ms=<milliseconds since the epoch>`, the time of its event, on both paths.
The userinfo of any URL in a line is replaced by `<credentials>`.

The lines,
each prefixed `cli-git-service:` on standard error and identified as `cli-git-service` in the journal:

- `started release="<release>" protocol=<n> pid=<pid> instance=<instance>` comes first on every start,
  then `ready`.
  An `instance=any` is a development run.
- `answered request="<id>" branch="<ref>" outcome=<word> ...` comes once per request answered.
  `outcome` is `landed`,
  `already` (a retry of a request that had landed),
  `retry-prepare`,
  `branch-moved` or
  `failed:<code>`.
  `batch`,
  `size` and `position` say which requests landed together.
  The phases,
  in order of time:
  `read_ms` (the client sending its line),
  `queued_ms` (waiting for the batch to start),
  `settle_ms` (finishing a batch a killed service left open),
  `landing_ms` and `answer_ms`;
  `total_ms` is the whole stay
  from the connection being accepted.
  `landing_ms` is the sum of `lock_wait_ms` (taking the index lock:
  flushing the marker to storage,
  `lock_stage_ms`,
  with `lock_stage_kind=pre` when the marker was made while the service was idle and `cold` when the commit made it,
  and waiting for a hold on the index,
  `lock_place_ms`,
  with `lock_holder="Process <pid>"`
  when the evidence names one,
  `lock_looks` for how many times the lock was found held,
  and `lock_found` and `lock_last` for what the first and the last look found:
  `service seq=.. pid=.. state=running|gone|unknown age_ms=..` for a lock with the service's marker,
  `foreign no-marker size=.. age_ms=..` for any other,
  with `open_by=[pid (comm)]` when a process has it open:
  the program's name,
  at most fifteen bytes,
  never its arguments or environment;
  the scan for them is made once,
  with the evidence for the waiting notice,
  and stops after a quarter of a second;
  the marker's birth identity,
  boot ID and PID namespace are not in the line),
  `reads_ms` (the branch reads and comparisons),
  `objects_ms`,
  `journal_ms`,
  `ref_ms`
  (moving the branch) and `index_update_ms` (removing the conclusion state, then building and installing the new index);
  records written before 2026-11-04 carry the same duration as `sync_ms`, which was never a disk sync;
  `lock_hold_ms` is how long the index lock was held, from its placement to its release (the branch move and the index
  build), and is no phase of that sum;
  `attempts` is more than one when the branch moved under the batch.
  Each figure is cut to whole milliseconds on its own,
  so a sum can fall short of its total by less than one per term.
  The line never holds the commit's message,
  its author or its address.
- `waited for the index lock batch=<n> result=taken|refused lock_wait_ms=.. lock_stage_ms=.. ...` comes when the lock was
  found held or when flushing the marker or waiting took a second or more,
  at the moment the wait ends;
  it carries the same `lock_*` fields as the `answered` line,
  and stays when the landing fails afterwards.
  A slow commit with a long `lock_stage_ms` and no `lock_looks` lost its time to the disk and not to another program.
- `pushed branch="<ref>" oid=<oid> items=<n> outcome=done|failed|skipped push_ms=<n>` comes once for each push
  the service ran for a branch after commits
  (a failed push is a `warning` and carries Git's words, credentials masked, after a colon),
  `automatic maintenance ran maintenance_ms=<n>` and `the review of <oid> is done review_ms=<n>`
  say the other work ran,
  and `settled the batch an earlier service left open batch=<n> decision=finish|drop` (a `warning`)
  comes for each batch a restart found open.
- `retire requested by a client; this service is release "<release>" and exits after its batch` comes once for each retire
  request the service reads,
  and a client sends one in every round while the old service is still there,
  so the number of
  these lines is at most `updateRounds` of the commit's line.
  The request carries no release,
  so the client's side is in its line:
  `releasesSeen`,
  `updateRounds`.
- `exiting reason=<idle|retire|error|signal-<n>> uptime_ms=<n>` comes last.
  `idle` is three hours with nothing to do,
  `retire` is a retire request,
  `error` is the service's queue closing,
  and
  `signal-15` is the manager stopping the unit.
  Uptime counts from the start of the process for a signal and from the start of serving for the others.
  A service killed by `SIGKILL` or the kernel's out-of-memory killer writes nothing,
  which is itself the evidence.

To split the slow commit of 2026-11-01 that showed `Updating the commit service...`,
read its line in
`cli-git-commit-timing.jsonl`:
a large `reachMs` with `updateRounds` above zero and two entries in `releasesSeen` is a service
of another release being replaced,
and the journal of the instance shows that service's `retire requested` and `exiting`
lines and the new `started` line;
a large `answerWaitMs` is the queue or a held index,
and the `answered` line of the
request in the journal says which,
and which phase of the landing.

The design, the measurements and the migration plan are in `doc/planning/cli-git-resident-service.md`.