omh 0.6.0

Launch any coding harness, in a sandbox, with your setup already there.
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
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
//! What the commands do, rather than what the functions under them return.
//!
//! Every guard in `memory.rs` is a pure function with a unit test, and that is
//! most of the value — but it is not the part a user meets. Twice now a guard
//! has been correct while the wiring that reaches it was missing or wrong, and
//! the suite stayed green both times: deleting `lint`'s whole exit-code block,
//! or `init`'s call to stage the note rules, changed nothing any test could
//! see. Those are the two failures this file exists to notice.
//!
//! Driven through the built binary, because an exit code is not observable
//! from inside the process that would have produced it, and `Paths` reads
//! `$HOME` — which a subprocess can own and an in-process test cannot.

use std::path::{Path, PathBuf};
use std::process::{Command, Output};

/// A repo and a home, isolated from the developer's own.
///
/// `repo_root` only looks for a `.git` directory, so an empty one is a repo as
/// far as omh is concerned, and most of this file needs no `git init` and no
/// git on the box. `promote` is the exception — it asks git whether the
/// destination is ignored and refuses to guess — so those tests call
/// `git_init` and do depend on git being installed.
struct Sandbox {
    _dir: tempfile::TempDir,
    repo: PathBuf,
    home: PathBuf,
    /// Prepended to `PATH`, so a test can put a recording `docker` in front of
    /// the developer's real one.
    bin: PathBuf,
}

fn sandbox() -> Sandbox {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    let home = dir.path().join("home");
    let bin = dir.path().join("bin");
    std::fs::create_dir_all(repo.join(".git")).unwrap();
    std::fs::create_dir_all(&home).unwrap();
    std::fs::create_dir_all(&bin).unwrap();
    Sandbox {
        _dir: dir,
        repo,
        home,
        bin,
    }
}

impl Sandbox {
    fn omh(&self, args: &[&str]) -> Output {
        let path = match std::env::var("PATH") {
            Ok(rest) => format!("{}:{rest}", self.bin.display()),
            Err(_) => self.bin.display().to_string(),
        };
        Command::new(env!("CARGO_BIN_EXE_omh"))
            .args(args)
            .current_dir(&self.repo)
            .env("HOME", &self.home)
            .env("PATH", path)
            .output()
            .expect("the binary under test must run")
    }

    /// A `docker` that records every invocation and claims the session
    /// container is up. Returns the log path.
    ///
    /// Which runtime gets picked is pinned too: `auto` prefers `sbx`, so on a
    /// box that has one this shim would never be consulted and the test would
    /// pass by not looking.
    ///
    /// `ps` lists the `containers` file, which is what omh's probe reads: it
    /// asks for the running set and compares names itself, so the shim has no
    /// pattern to honour. An earlier version of this tried to emulate
    /// `--filter name=` and got the containment backwards — it tested whether
    /// the container name was a substring of the argv, so a shim asked about
    /// `omh-repo-s10` reported `omh-repo-s1` as running. Nothing in the tree
    /// needs that emulation now.
    ///
    /// Writing `docker-refuses` into the bin directory makes the shim exit
    /// non-zero, which is how a runtime that cannot be reached is reachable
    /// from a test at all. `docker-exec-refuses` fails only `exec`, which is
    /// the narrower thing the launch path needs: a runtime that answers *the
    /// container is running* and then will not let omh in. Everything
    /// destructive in a launch hangs off telling those two apart.
    fn fake_docker(&self) -> PathBuf {
        let log = self.bin.join("docker.log");
        let shim = self.bin.join("docker");
        std::fs::write(
            &shim,
            format!(
                "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\n\
                 [ -f {refuses} ] && {{ echo 'cannot connect to the daemon' >&2; exit 1; }}\n\
                 if [ \"$1\" = exec ] && [ -f {exec_refuses} ]; then \
                 cat {exec_refuses} >&2; exit 1; fi\n\
                 if [ \"$1\" = inspect ]; then echo true; fi\n\
                 if [ \"$1\" = ps ]; then cat {containers} 2>/dev/null; fi\nexit 0\n",
                log = log.display(),
                refuses = self.bin.join("docker-refuses").display(),
                exec_refuses = self.bin.join("docker-exec-refuses").display(),
                containers = self.bin.join("containers").display()
            ),
        )
        .unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
        std::fs::create_dir_all(self.repo.join(".omh")).unwrap();
        std::fs::write(
            self.repo.join(".omh/settings.toml"),
            "runtime = \"docker\"\n",
        )
        .unwrap();
        log
    }

    /// A `docker` that has no images yet, so a build actually happens, and
    /// answers `images`/`ps` from files the test seeds.
    ///
    /// Separate from `fake_docker` because that one exits 0 for everything:
    /// `image inspect` succeeds, `exists()` reports the tag present, and the
    /// build — and therefore the reap — is skipped before it is reached. A
    /// shim that says yes to everything cannot test the path taken when
    /// something is missing.
    fn fake_docker_with_nothing_built(&self, tags: &[&str], in_use: &[&str]) -> PathBuf {
        let log = self.bin.join("docker.log");
        let images = self.bin.join("images");
        let containers = self.bin.join("containers");
        std::fs::write(&images, format!("{}\n", tags.join("\n"))).unwrap();
        std::fs::write(&containers, format!("{}\n", in_use.join("\n"))).unwrap();
        let shim = self.bin.join("docker");
        std::fs::write(
            &shim,
            format!(
                "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\n\
                 case \"$1 $2\" in\n\
                 \"image inspect\") exit 1 ;;\n\
                 \"image rm\") echo \"Untagged: $3\"; echo 'Deleted: sha256:00'; exit 0 ;;\n\
                 esac\n\
                 # omh sends the Dockerfile on stdin (`-f -`) so nothing is\n\
                 # written to disk. A shim that exits without reading it leaves\n\
                 # omh writing into a pipe with no reader, and omh sets SIGPIPE\n\
                 # to SIG_DFL on purpose — so it dies of signal 13, silently,\n\
                 # whenever the Dockerfile loses the race with this exit. That\n\
                 # is what failed this test on the linux runner three times\n\
                 # across three branches, each time saying only `init failed`.\n\
                 if [ \"$1\" = build ]; then cat > /dev/null; fi\n\
                 if [ \"$1\" = images ]; then cat {images}; fi\n\
                 if [ \"$1\" = ps ]; then cat {containers}; fi\n\
                 if [ \"$1\" = inspect ]; then echo true; fi\nexit 0\n",
                log = log.display(),
                images = images.display(),
                containers = containers.display(),
            ),
        )
        .unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
        std::fs::create_dir_all(self.repo.join(".omh")).unwrap();
        std::fs::write(
            self.repo.join(".omh/settings.toml"),
            "runtime = \"docker\"\n",
        )
        .unwrap();
        log
    }

    fn docker_calls(&self, log: &Path) -> Vec<String> {
        std::fs::read_to_string(log)
            .unwrap_or_default()
            .lines()
            .map(str::to_string)
            .collect()
    }

    /// Put the shipped base manifest where `Paths::base()` looks.
    ///
    /// `omh init` would do it, and needs a container runtime to finish — so the
    /// commands that only read the manifest get it this way instead, and stay
    /// runnable on a box with no docker.
    fn seed_base(&self) {
        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("base");
        let dst = self.home.join(".omh/base");
        std::fs::create_dir_all(&dst).unwrap();
        for entry in std::fs::read_dir(src).unwrap().flatten() {
            std::fs::copy(entry.path(), dst.join(entry.file_name())).unwrap();
        }
    }

    fn settings(&self) -> String {
        std::fs::read_to_string(self.repo.join(".omh/settings.toml")).unwrap_or_default()
    }

    fn catalogue(&self, entries: &[&str]) {
        for entry in entries {
            let p = self.home.join(".omh").join(entry);
            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
            std::fs::write(p, "x").unwrap();
        }
    }

    fn local_store(&self) -> PathBuf {
        self.home
            .join(".omh/notes")
            .join(self.repo.file_name().unwrap())
            .join("local")
    }

    fn seed(&self, at: &str, body: &str) {
        let path = self.local_store().join(at);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, body).unwrap();
    }

    /// A real repository, for the commands that ask git a question rather than
    /// just needing somewhere to be. `promote` is the only one: it will not
    /// plan against a destination it cannot establish the ignore status of,
    /// and the empty `.git` above is exactly the case git refuses to answer
    /// about — so a promotion in the bare sandbox is correctly always blocked.
    fn git_init(&self) {
        std::fs::remove_dir_all(self.repo.join(".git")).unwrap();
        let out = Command::new("git")
            .arg("-C")
            .arg(&self.repo)
            .args(["init", "-q", "-b", "main"])
            .output()
            .expect("git must be installed to run this test");
        assert!(out.status.success(), "git init failed");
    }

    /// A sandbox repository holding one commit no branch has.
    ///
    /// Deliberately minimal, and **not** a re-run of `Shadow::ensure`: it needs
    /// a seed record and a commit past it, which is all `unkept_work` reads.
    /// Getting it wrong makes the test fail loudly — `rm` would succeed and the
    /// assertion is that it refuses — rather than pass over a fixture that
    /// proved nothing, which is the failure mode that kept shadows out of this
    /// file until now.
    fn sandbox_repo_with_unkept_work(&self, id: &str, worktree: &std::path::Path) {
        let shadow = self
            .home
            .join(".omh/shadow")
            .join(self.repo.file_name().unwrap());
        std::fs::create_dir_all(&shadow).unwrap();
        let gitdir = shadow.join(format!("{id}.git"));
        let git = |args: &[&str]| {
            let out = Command::new("git")
                .arg("--git-dir")
                .arg(&gitdir)
                .arg("--work-tree")
                .arg(worktree)
                .args(args)
                .output()
                .expect("git must be installed to run this test");
            assert!(out.status.success(), "git {args:?}: {out:?}");
            String::from_utf8_lossy(&out.stdout).trim().to_string()
        };
        Command::new("git")
            .args(["init", "-q", "--bare"])
            .arg(&gitdir)
            .output()
            .unwrap();
        git(&["config", "user.email", "sandbox@omh.invalid"]);
        git(&["config", "user.name", "omh sandbox"]);
        git(&["commit", "-q", "--allow-empty", "--no-verify", "-m", "seed"]);
        std::fs::write(
            shadow.join(format!("{id}.seed")),
            git(&["rev-parse", "HEAD"]),
        )
        .unwrap();
        std::fs::write(worktree.join("agent.rs"), "fn agent() {}\n").unwrap();
        git(&["add", "-A", "."]);
        git(&["commit", "-q", "--no-verify", "-m", "the agent's own work"]);
    }

    /// Where a branch points, for asserting that it did not move.
    fn head_of_branch(&self, branch: &str) -> String {
        let out = Command::new("git")
            .arg("-C")
            .arg(&self.repo)
            .args(["rev-parse", branch])
            .output()
            .expect("git must be installed to run this test");
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    }

    fn team_store(&self) -> PathBuf {
        self.repo.join(".omh/notes")
    }
}

fn note(key: &str, body: &str) -> String {
    format!(
        "---\nkey: {key}\ntype: surprise\nsource: audit\nrecorded: 2026-08-10\n---\n\n# T\n\n{body}"
    )
}

/// A note the schema has nothing to refuse, so what `lint` reports about it is
/// warnings and only warnings. Every required `surprise` section is here —
/// including `## Answers`, without which this fixture would be testing a
/// refusal rather than the warning it is named for.
const WHOLE: &str =
    "## Expected\na\n\n## Observed\nb\n\n## Evidence\nc\n\n## Answers\n\n- what happens here\n";

/// §14 makes this exit code M1's entire stand-in for the refused write the
/// agent does not get yet. A gate that cannot fail gates nothing: no hook, no
/// CI step and no `&&` can read it.
#[test]
fn lint_fails_the_command_when_the_schema_refused_something() {
    let sb = sandbox();
    sb.seed("broken.md", &note("broken", "## Expected\na\n"));

    let out = sb.omh(&["memory", "lint"]);
    assert!(
        !out.status.success(),
        "a store with refusals must fail the command"
    );
    let printed = String::from_utf8_lossy(&out.stdout);
    assert!(
        printed.contains("refused"),
        "the report is the product and prints before the exit code: {printed}"
    );
}

/// The other half, and the reason the gate reads severity rather than
/// counting: `Orphan` fires on every note nothing links to, which is every
/// note `remember` writes without `--relates-to`. A gate that tripped on
/// those would be red for every real store.
#[test]
fn lint_passes_a_store_that_only_has_warnings() {
    let sb = sandbox();
    sb.seed("fine.md", &note("fine", WHOLE));

    let out = sb.omh(&["memory", "lint"]);
    assert!(
        out.status.success(),
        "warnings must not fail the command: {}",
        String::from_utf8_lossy(&out.stdout)
    );
    assert!(String::from_utf8_lossy(&out.stdout).contains("warning"));
}

/// `--at` exists to reach one of two notes that share a key. Naming a file
/// that holds neither must never fall through to deleting one of them.
#[test]
fn rm_refuses_an_at_that_names_no_note() {
    let sb = sandbox();
    sb.seed("solo.md", &note("solo", WHOLE));

    let out = sb.omh(&["memory", "rm", "solo", "--at", "elsewhere.md"]);
    assert!(!out.status.success());
    assert!(
        sb.local_store().join("solo.md").exists(),
        "a note the caller did not name was removed"
    );
}

/// The escape this store's guards exist for, end to end: a key template is a
/// committed file, so a clone carries it.
#[test]
fn remember_refuses_a_key_template_that_leaves_the_store() {
    let sb = sandbox();
    std::fs::create_dir_all(sb.repo.join(".omh")).unwrap();
    std::fs::write(
        sb.repo.join(".omh/memory.toml"),
        "[keys]\nsurprise = \"../../escaped/{{slug}}\"\ntopic = \"{{slug}}\"\nstub = \"docs/{{path}}\"\n",
    )
    .unwrap();

    let out = sb.omh(&[
        "memory",
        "remember",
        "--expected",
        "a",
        "--observed",
        "the mount failed",
        "--evidence",
        "c",
    ]);
    assert!(!out.status.success());
    assert!(
        !escaped_notes(sb.home.parent().unwrap()),
        "a note was written outside the store"
    );
}

fn escaped_notes(under: &Path) -> bool {
    let mut stack = vec![under.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.extension().is_some_and(|e| e == "md")
                && !path.components().any(|c| c.as_os_str() == "notes")
            {
                return true;
            }
        }
    }
    false
}

/// `promote` is the one command whose failure must not be quiet: it is the
/// human gate, and a gate that reports a refusal only on stdout — or exits 0
/// having refused — is a gate somebody scripts straight past. Nothing under
/// `plan` can observe either, because both live in `main`.
#[test]
fn promote_fails_the_command_and_moves_nothing_when_a_key_is_blocked() {
    let sb = sandbox();
    sb.git_init();
    sb.seed("private.md", &note("private", WHOLE));
    sb.seed(
        "candidate.md",
        &note(
            "candidate",
            &format!("{WHOLE}\n## Related\n\n- [[private]]\n"),
        ),
    );

    let out = sb.omh(&["memory", "promote", "candidate"]);
    assert!(
        !out.status.success(),
        "a refused promotion must fail the command"
    );
    let said = String::from_utf8_lossy(&out.stderr);
    assert!(
        said.contains("private"),
        "the blocker names what to fix, on stderr: {said}"
    );
    assert!(
        sb.local_store().join("candidate.md").exists(),
        "and the note is still in the gitignored layer"
    );
    assert!(
        !sb.team_store().join("candidate.md").exists(),
        "and nothing was committed-layer written"
    );
}

/// The other half. Without it the test above passes on a `promote` that
/// refuses everything, which is the failure mode a fail-closed ignore check
/// makes easy to ship.
#[test]
fn promote_moves_the_note_and_says_it_is_not_shared_yet() {
    let sb = sandbox();
    sb.git_init();
    sb.seed("fine.md", &note("fine", WHOLE));

    let out = sb.omh(&["memory", "promote", "fine"]);
    assert!(
        out.status.success(),
        "a clean note promotes: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let printed = String::from_utf8_lossy(&out.stdout);
    assert!(
        printed.contains("not shared until committed"),
        "moving the file is not sharing it: {printed}"
    );
    assert!(
        sb.team_store().join("fine.md").exists(),
        "the note is in the committed layer"
    );
    assert!(
        !sb.local_store().join("fine.md").exists(),
        "and no longer in the gitignored one"
    );
}

/// The hash git would record for a file, so a fixture can pin the real thing
/// rather than a value that is stale by construction.
fn hash_object(repo: &Path, rel: &str) -> String {
    let out = Command::new("git")
        .arg("-C")
        .arg(repo)
        .args(["hash-object", "--"])
        .arg(rel)
        .output()
        .expect("git must be installed to run this test");
    assert!(out.status.success(), "git hash-object failed");
    String::from_utf8(out.stdout).unwrap().trim().to_string()
}

fn note_expiring(key: &str, trigger: &str) -> String {
    format!(
        "---\nkey: {key}\ntype: surprise\nsource: audit\nrecorded: 2026-08-10\n\
         invalidated_by: {trigger}\n---\n\n# T\n\n{WHOLE}"
    )
}

/// **`stale` said nothing at the only boundary a script reads.** Four notes
/// stale exited 0; git missing so that not one probe could be answered exited
/// 0; an empty store exited 0. `lint` in the same file has bothered to bail
/// since M1, and CI cannot tell "the store is clean" from "omh checked
/// nothing".
#[test]
fn stale_fails_the_command_when_a_note_is_out_of_date() {
    let sb = sandbox();
    sb.git_init();
    std::fs::write(sb.repo.join("t.txt"), "before\n").unwrap();
    sb.seed("pinned.md", &note_expiring("pinned", "file:t.txt@0000000"));

    let out = sb.omh(&["memory", "stale"]);
    assert_eq!(
        out.status.code(),
        Some(1),
        "a stale store must fail: {}",
        String::from_utf8_lossy(&out.stdout)
    );
    let printed = String::from_utf8_lossy(&out.stdout);
    assert!(
        printed.contains("stale"),
        "the report is the product: {printed}"
    );
}

/// The other half, or the test above passes on a `stale` that always fails.
#[test]
fn stale_exits_zero_when_every_note_is_current() {
    let sb = sandbox();
    sb.git_init();
    std::fs::write(sb.repo.join("t.txt"), "before\n").unwrap();
    let real = hash_object(&sb.repo, "t.txt");
    sb.seed(
        "pinned.md",
        &note_expiring("pinned", &format!("file:t.txt@{real}")),
    );

    let out = sb.omh(&["memory", "stale"]);
    assert_eq!(
        out.status.code(),
        Some(0),
        "nothing is stale: {}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
}

/// **"omh cannot tell" is not "fine".** Folding it into 0 is the same lie the
/// `Unknown` verdict exists to refuse, arriving one layer later — a scripted
/// caller reads the code, not the prose.
#[test]
fn stale_reports_a_separate_code_when_it_cannot_tell() {
    let sb = sandbox();
    sb.git_init();
    // `symbol:` is unanswerable from the host by design.
    sb.seed("sym.md", &note_expiring("sym", "symbol:GUEST_HOME"));

    let out = sb.omh(&["memory", "stale"]);
    assert_eq!(
        out.status.code(),
        Some(2),
        "cannot-tell has its own code: {}",
        String::from_utf8_lossy(&out.stdout)
    );
}

/// The grouping is the last hop, and the heading a note lands under is the
/// whole claim. Swapping the two headings, or filing `Unknown` under `stale`,
/// kept the suite green while `contributing.md` listed the opposite as guarded.
#[test]
fn stale_never_files_what_it_cannot_tell_under_stale() {
    let sb = sandbox();
    sb.git_init();
    sb.seed("sym.md", &note_expiring("sym", "symbol:GUEST_HOME"));

    let printed = String::from_utf8_lossy(&sb.omh(&["memory", "stale"]).stdout).to_string();
    let cannot = printed.find("omh cannot tell").expect(&printed);
    let key = printed.find("sym").expect(&printed);
    assert!(
        printed.find("stale:").is_none(),
        "nothing is known to be stale here: {printed}"
    );
    assert!(
        key > cannot,
        "the note belongs under that heading: {printed}"
    );
}

// ── getting work out of a session ───────────────────────────────────────────

impl Sandbox {
    /// A session as omh would have left one: a real worktree on `omh/<id>`.
    ///
    /// Built with plain git rather than by launching a container, because what
    /// these tests are about is the host-side path out of a session — the half
    /// that has to work whether or not a sandbox is running.
    fn session(&self, id: &str) -> PathBuf {
        let origin = self._dir.path().join("origin.git");
        Command::new("git")
            .args(["init", "-q", "--bare"])
            .arg(&origin)
            .output()
            .expect("git must be installed to run this test");
        let git = |args: &[&str]| {
            let out = Command::new("git")
                .arg("-C")
                .arg(&self.repo)
                .args(args)
                .output()
                .expect("git must be installed to run this test");
            assert!(out.status.success(), "git {args:?}: {out:?}");
        };
        // Once per repository, so a test can ask for a second session. It could
        // not before — `git_init` *deletes* `.git`, so a second call left the
        // first session's worktree pointing at a gitdir that no longer existed
        // and every command in it answered `not a git repository: (null)`. That
        // is why every test naming a session had exactly one to name, which is
        // the arrangement where `--session s01` and naming nothing give the
        // same answer and a selector that did nothing would pass them all.
        let first_session = Command::new("git")
            .arg("-C")
            .arg(&self.repo)
            .args(["remote"])
            .output()
            .is_ok_and(|o| !o.status.success() || o.stdout.is_empty());
        if first_session {
            self.git_init();
            git(&["config", "user.email", "t@example.com"]);
            git(&["config", "user.name", "t"]);
            git(&["commit", "-q", "--allow-empty", "-m", "root"]);
            git(&["remote", "add", "origin", origin.to_str().unwrap()]);
        }

        let worktree = self
            .home
            .join(".omh/worktrees")
            .join(self.repo.file_name().unwrap())
            .join(id);
        std::fs::create_dir_all(worktree.parent().unwrap()).unwrap();
        git(&[
            "worktree",
            "add",
            "-q",
            worktree.to_str().unwrap(),
            "-b",
            &format!("omh/{id}"),
        ]);
        worktree
    }
}

/// The shipped hook body, with the guest's paths swapped for a fixture's.
///
/// Written here rather than imported: this file drives the binary and cannot
/// reach `shadow::turn_hook_command`. The unit test in `shadow.rs` runs the
/// real function; this only gets a fixture a snapshot to look at.
fn omh_turn_hook_body(gitdir: &std::path::Path, worktree: &std::path::Path) -> String {
    let g = format!(
        "git -C {w} --git-dir={g} --work-tree={w}",
        w = worktree.display(),
        g = gitdir.display()
    );
    format!(
        "{{ i={i}; GIT_INDEX_FILE=$i {g} read-tree HEAD && GIT_INDEX_FILE=$i {g} add -A \
         && t=$(GIT_INDEX_FILE=$i {g} write-tree) \
         && p=$({g} rev-parse -q --verify refs/omh/turn || true) \
         && if [ -n \"$p\" ] && [ \"$({g} rev-parse \"$p^{{tree}}\")\" = \"$t\" ]; then :; \
         else c=$({g} commit-tree \"$t\" ${{p:+-p}} ${{p:+\"$p\"}} -m \"turn end\") \
         && {g} update-ref refs/omh/turn \"$c\"; fi; }} >/dev/null 2>&1 || true",
        i = gitdir.join("omh-turn.index").display()
    )
}

/// `omh sNN rm` names the snapshots it is about to delete.
///
/// The wiring, not the decision: `may_remove` decides and is unit-tested with
/// a count handed to it, so nothing proved that `rm` asks for a real one.
/// Replacing that call with a literal `0` left the whole suite green.
#[test]
fn rm_names_the_snapshots_it_takes_and_force_still_removes() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    sb.sandbox_repo_with_unkept_work("s01", &worktree);
    let gitdir = sb
        .home
        .join(".omh/shadow")
        .join(sb.repo.file_name().unwrap())
        .join("s01.git");

    std::fs::write(worktree.join("in-flight.rs"), "fn later() {}\n").unwrap();
    Command::new("sh")
        .arg("-c")
        .arg(omh_turn_hook_body(&gitdir, &worktree))
        .output()
        .expect("sh must be installed");

    let refused = sb.omh(&["s01", "rm"]);
    assert!(!refused.status.success(), "unharvested work still refuses");
    let said = String::from_utf8_lossy(&refused.stderr);
    assert!(
        said.contains("1 turn snapshot"),
        "and the count is a real one, asked of the sandbox: {said}"
    );
    assert!(
        said.contains("omh s01 log --turns"),
        "with the command that reads them: {said}"
    );

    assert!(
        sb.omh(&["s01", "rm", "--force"]).status.success(),
        "and `--force` still means it"
    );
}

/// `omh sNN log --turns` reads omh's own snapshots, and the default view does
/// not show them.
///
/// End to end because the separation is the design: one parser, two views, two
/// commands. The unit tests decide what each says; this decides that asking
/// for one never hands you the other — and that a snapshot sitting in the
/// sandbox does not make the ordinary `log` start warning about work on no
/// branch, which is what all three ref-walking guards would have done.
#[test]
fn log_turns_reads_the_snapshots_and_the_default_view_does_not() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    sb.sandbox_repo_with_unkept_work("s01", &worktree);
    let gitdir = sb
        .home
        .join(".omh/shadow")
        .join(sb.repo.file_name().unwrap())
        .join("s01.git");

    std::fs::write(worktree.join("in-flight.rs"), "fn later() {}\n").unwrap();
    let ran = Command::new("sh")
        .arg("-c")
        .arg(omh_turn_hook_body(&gitdir, &worktree))
        .output()
        .expect("sh must be installed");
    assert!(ran.status.success(), "the hook never fails a turn: {ran:?}");

    let turns = sb.omh(&["s01", "log", "--turns"]);
    let printed = String::from_utf8_lossy(&turns.stdout);
    assert!(
        printed.contains("1 turn"),
        "the snapshot is there to read: {printed}"
    );

    // The default view is untouched, and — the part that matters — nothing
    // warns. All three guards would have called this snapshot stranded work.
    let plain = sb.omh(&["s01", "log"]);
    let out = String::from_utf8_lossy(&plain.stdout);
    let err = String::from_utf8_lossy(&plain.stderr);
    // Anchored: every assertion below is negative, and a `log` that exited 1
    // with empty stdout would satisfy all of them.
    assert!(plain.status.success(), "the default log works: {err}");
    assert!(
        out.contains("agent"),
        "and lists the agent's own commit: {out}"
    );
    assert!(
        !out.contains("turn end"),
        "omh's own snapshot is not in the agent's list: {out}"
    );
    assert!(
        !err.contains("on no branch") && !out.contains("on no branch"),
        "and nothing calls it work the agent stranded: {out}{err}"
    );
}

/// Asking to see the agent's work before the agent has run is an ordinary
/// thing to do, and the answer is *nothing yet* rather than a failure.
///
/// A session whose sandbox has never started has no repository to read at all:
/// `checkpoints` would ask for the seed and get "no seed recorded", which is
/// true and is not what the user asked. The reading itself is unit-tested
/// against a real sandbox repository in `shadow.rs` — building one here would
/// mean a fixture that reimplements `ensure`, and a fixture that reimplements
/// the thing it tests proves whichever of the two is wrong.
#[test]
fn a_log_for_a_sandbox_that_never_ran_says_nothing_yet() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("in-progress.rs"), "fn main() {}").unwrap();

    let out = sb.omh(&["s01", "log"]);
    let printed = String::from_utf8_lossy(&out.stdout);
    let said = String::from_utf8_lossy(&out.stderr);

    assert!(
        out.status.success(),
        "there is nothing wrong with an empty sandbox: {said}"
    );
    assert!(
        printed.contains("no checkpoints"),
        "it says so plainly: {printed}"
    );
    // Zero, and correctly: the count is what `--keep` would sweep out of the
    // *sandbox*, and there is no sandbox. The file sitting in the worktree is a
    // fact about the session, which `omh s` and `omh s diff` answer — this
    // line answers what the harvest is about to do, so it must not borrow a
    // number measured somewhere else.
    assert!(
        printed.contains("uncommitted in the sandbox: 0 files"),
        "nothing is staged for a harvest that has nothing to harvest: {printed}"
    );
}

/// `--json` never hands the terminal to a pager, and says which of the two it
/// gave you.
///
/// A script asking for a patch gets the patch as a field. Paging is for a
/// person, and `less` between a program and the object it asked for is a hang
/// with no error — the failure mode that has no output to diagnose it by.
///
/// The **key** is the other half. `Diff`'s own doc comment argued the field
/// should be named for what it holds, because `jq -r .patch | git apply` on a
/// `--stat` fails on every session that changed anything; `-p` then put a real
/// patch under `summary`, which is that footgun with the labels swapped. One
/// key or the other, never both, so a script can tell without sniffing for
/// `@@`.
///
/// Asserted through the binary rather than on the branch inside `diff`,
/// because the branch is the thing that could be wrong: a unit test of the two
/// arms would agree with whichever one was written.
#[test]
fn a_patch_asked_for_by_a_program_is_a_field_named_for_what_it_holds() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("feature.rs"), "fn added() {}\n").unwrap();

    let read = |args: &[&str]| -> serde_json::Value {
        let out = sb.omh(args);
        let printed = String::from_utf8_lossy(&out.stdout).to_string();
        assert!(
            out.status.success(),
            "{args:?}: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        serde_json::from_str(&printed).unwrap_or_else(|e| panic!("not JSON: {e}: {printed}"))
    };

    let patch = read(&["s01", "diff", "-p", "--json"]);
    assert_eq!(patch["changed"], serde_json::json!(true));
    assert!(
        patch["patch"]
            .as_str()
            .is_some_and(|s| s.contains("+fn added() {}")),
        "the patch itself, under `patch`: {patch}"
    );
    assert!(
        patch["summary"].is_null(),
        "and not also under `summary`: {patch}"
    );

    let summary = read(&["s01", "diff", "--json"]);
    assert!(
        summary["summary"]
            .as_str()
            .is_some_and(|s| s.contains("feature.rs") && !s.contains("+fn added() {}")),
        "a --stat, under `summary`: {summary}"
    );
    assert!(
        summary["patch"].is_null(),
        "nothing a script could hand to `git apply`: {summary}"
    );
    assert_eq!(
        summary["session"],
        serde_json::json!("s01"),
        "the id, not a phrase: {summary}"
    );
}

/// The flag is the only thing that decides which of the two you get.
///
/// End to end, because the wiring is what could be wrong: hardcoding
/// `What::Patch` in `diff_report`'s `false` arm left the whole suite green
/// while `omh sNN diff` dumped a full patch to stdout unbidden. Every existing
/// test asserted the file was *named*, which a patch also does.
///
/// No pty is needed: git suppresses its pager when stdout is not a terminal,
/// so the patch arrives captured.
#[test]
fn the_flag_is_what_decides_whether_a_diff_is_a_summary_or_a_patch() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("feature.rs"), "fn added() {}\n").unwrap();

    let summary = String::from_utf8_lossy(&sb.omh(&["s01", "diff"]).stdout).to_string();
    let patch = String::from_utf8_lossy(&sb.omh(&["s01", "diff", "-p"]).stdout).to_string();

    assert!(
        summary.contains("feature.rs") && !summary.contains("+fn added() {}"),
        "without the flag, the shape of the change: {summary}"
    );
    assert!(
        patch.contains("+fn added() {}"),
        "with it, the change: {patch}"
    );
}

/// `--edit` without a terminal refuses rather than reporting a curation that
/// never happened.
///
/// Measured before this landed: with stdin not a terminal, `rebase -i` runs
/// the **unedited** todo, exits 0, and omh reports the work as curated. So the
/// flag that opens an editor is the one that has to ask whether there is
/// anywhere to draw — and it is now the only path that needs one, which is
/// what makes a single guard enough.
///
/// A test process has no tty, so this is the ordinary case here rather than a
/// contrived one.
#[test]
fn edit_without_a_terminal_refuses_rather_than_pretending_to_curate() {
    let sb = sandbox();
    sb.session("s01");

    let out = sb.omh(&["s01", "commit", "--keep", "--edit"]);
    let said = String::from_utf8_lossy(&out.stderr);

    assert!(!out.status.success(), "there is nowhere to draw: {said}");
    assert!(
        said.contains("no terminal"),
        "and it says why, rather than reporting a curation: {said}"
    );
    assert!(
        said.contains("--keep 1,3-4") || said.contains("Drop `--edit`"),
        "with something to do instead: {said}"
    );
}

/// A selection is refused before the branch moves.
///
/// The refusals themselves are asserted in `src/main.rs`, against a session
/// with a real sandbox behind it — an earlier version of this test lived here
/// alone and proved nothing: `sb.session()` builds a worktree and no sandbox
/// repository, so `9`, `0`, `two` and `4-2` all died identically inside
/// `seed()`, about a record the user has never heard of. Gutting the parser
/// left it green.
///
/// What is left here is the half that needs the whole binary: whatever the
/// refusal says, the branch is where it was.
#[test]
fn a_refused_selection_leaves_the_branch_where_it_was() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("work.rs"), "fn work() {}\n").unwrap();
    let before = sb.head_of_branch("omh/s01");

    for selection in ["9", "0", "two", "4-2"] {
        let out = sb.omh(&["s01", "commit", "--keep", selection]);
        assert!(
            !out.status.success(),
            "`--keep {selection}` was accepted: {}",
            String::from_utf8_lossy(&out.stdout)
        );
    }
    assert_eq!(
        sb.head_of_branch("omh/s01"),
        before,
        "the branch never moved"
    );
}

/// Two sessions changing one file are named together.
///
/// The collision git will not mention until a merge, said while both sessions
/// are open and either could be redirected. End to end, because it is wiring:
/// the paths come from a `status --porcelain` that `omh s` already ran for its
/// uncommitted count and used to throw away, and the grouping is a table in
/// `report.rs` that a unit test cannot connect to the sessions on disk.
#[test]
fn sessions_changing_the_same_file_are_named_together() {
    let sb = sandbox();
    let one = sb.session("s01");
    let two = sb.session("s02");
    for (worktree, extra) in [(&one, "only-in-s01.rs"), (&two, "only-in-s02.rs")] {
        std::fs::write(worktree.join("shared.rs"), "fn shared() {}\n").unwrap();
        std::fs::write(worktree.join(extra), "fn mine() {}\n").unwrap();
    }

    let printed = String::from_utf8_lossy(&sb.omh(&["s"]).stdout).to_string();

    assert!(
        printed.contains("s01 and s02 both change shared.rs"),
        "the file both are changing, and who: {printed}"
    );
    assert!(
        !printed.contains("only-in-s01.rs"),
        "and nothing about what only one of them touches: {printed}"
    );

    // A collision is a fact about *two* sessions, so it has to survive being
    // asked about one of them — it is the most useful line on that screen.
    // This is also why the focused view still reads every session: the other
    // one's paths are what make the line sayable.
    let focused = String::from_utf8_lossy(&sb.omh(&["s01"]).stdout).to_string();
    assert!(
        focused.contains("s01 and s02 both change shared.rs"),
        "a collision involving s01 survives the focus: {focused}"
    );

    // Part of the answer rather than an aside: this is the most consequential
    // line in a record of what is in flight, and stderr is not where a
    // redirected listing keeps it.
    assert!(
        !String::from_utf8_lossy(&sb.omh(&["s"]).stderr).contains("both change"),
        "it is the answer, not a warning"
    );

    // The document says what the sentence says. `--json` is the scripting
    // contract, and deleting the field entirely left every other assertion
    // here green.
    let doc: serde_json::Value = serde_json::from_slice(&sb.omh(&["s", "--json"]).stdout).unwrap();
    assert_eq!(
        doc["overlaps"],
        serde_json::json!([{"sessions": ["s01", "s02"], "paths": ["shared.rs"]}]),
        "one answer, two renderings: {doc}"
    );
    assert_eq!(doc["unreadable"], serde_json::json!([]));
}

/// A session omh cannot read is said so, because its absence from the overlap
/// section otherwise means it collides with nobody.
///
/// A stale `.git` pointer is the real case — `work_state`'s own comment names
/// it, "a checkout moves" — and the listing renders that as `?` in one column
/// while the section below quietly computes over a subset. No overlap line is
/// exactly how "no collisions" looks.
#[test]
fn a_session_omh_cannot_read_is_named_rather_than_left_out() {
    let sb = sandbox();
    let one = sb.session("s01");
    let two = sb.session("s02");
    std::fs::write(one.join("shared.rs"), "fn shared() {}\n").unwrap();
    std::fs::write(two.join("shared.rs"), "fn shared() {}\n").unwrap();
    // s02's worktree loses its way back to the repository.
    std::fs::write(two.join(".git"), "gitdir: /nowhere-at-all\n").unwrap();

    let out = sb.omh(&["s"]);
    let printed = String::from_utf8_lossy(&out.stdout).to_string();

    assert!(
        printed.contains("could not read what s02 is changing"),
        "the session omh could not read is named: {printed}"
    );
    assert!(
        printed.contains("incomplete"),
        "and what that means for the rest: {printed}"
    );
    // …and the collision it would have been part of is not asserted as absent.
    assert!(
        !printed.contains("s01 and s02 both change"),
        "omh does not invent a collision it could not check: {printed}"
    );
}

/// A sandbox repository with no session is reported, not left to rot.
///
/// [risks](../docs/design/risks.md) 8c. The most valuable of the three orphans
/// `omh s` looks for: a container is re-creatable and a run directory holds a
/// timestamp, while this holds every commit an agent made and nothing points
/// at it.
#[test]
fn a_sandbox_repository_with_no_session_is_reported() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    // s01 gets a real repository too, so the live-session filter has something
    // to filter. Without it this test passed with the filter deleted: `s01`
    // had no shadow, so it was never in the list to be removed from.
    sb.sandbox_repo_with_unkept_work("s01", &worktree);
    let orphan = sb
        .home
        .join(".omh/shadow")
        .join(sb.repo.file_name().unwrap())
        .join("s09.git");
    std::fs::create_dir_all(&orphan).unwrap();

    let out = sb.omh(&["s"]);
    let said = String::from_utf8_lossy(&out.stderr);

    assert!(
        said.contains("s09"),
        "a repository nothing points at is named: {said}"
    );
    assert!(
        !said.contains("s01"),
        "and a session that is still here is not — every live session has a \
         repository, so this filter is the only thing between a healthy checkout \
         and being told to `rm` all of it: {said}"
    );

    // The hint is only worth printing if it works. `--force` because the
    // orphan holds a commit, which is #58 doing its job.
    assert!(
        sb.omh(&["s09", "rm", "--force"]).status.success(),
        "the hint `omh s` prints has to be a command that clears it"
    );
    assert!(!orphan.exists(), "and it did");
    assert!(
        !String::from_utf8_lossy(&sb.omh(&["s"]).stderr).contains("s09"),
        "so a second listing no longer names it"
    );
}

/// `rm` refuses over work that exists nowhere else, and `--force` is the way
/// past.
///
/// End to end, because the guard's whole value is being *reached*: the
/// decision is a table in `src/main.rs`, and this is the half that says `rm`
/// asks it. Removing the call left that table green.
#[test]
fn removing_a_session_holding_unkept_work_is_refused_until_it_is_meant() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    sb.sandbox_repo_with_unkept_work("s01", &worktree);

    let log = sb.fake_docker();
    let run = sb
        .home
        .join(".omh/run")
        .join(sb.repo.file_name().unwrap())
        .join("s01");
    std::fs::create_dir_all(&run).unwrap();
    let gitdir = sb
        .home
        .join(".omh/shadow")
        .join(sb.repo.file_name().unwrap())
        .join("s01.git");

    let out = sb.omh(&["s01", "rm"]);
    let said = String::from_utf8_lossy(&out.stderr);
    assert!(
        !out.status.success(),
        "the agent's commit is on no branch: {said}"
    );
    assert!(
        said.contains("s01 has 1 commit that no branch has"),
        "it says what is at stake, in the singular: {said}"
    );
    assert!(said.contains("--force"), "and how to mean it: {said}");

    // "Nothing was taken down" is about the things that go *first*. The
    // worktree is removed last, so its survival is true of any ordering that
    // fails anywhere — moving the guard below the container teardown would
    // leave it standing and prove nothing.
    assert!(
        !sb.docker_calls(&log).iter().any(|c| c.starts_with("rm ")),
        "the container was taken down on the way to refusing: {:?}",
        sb.docker_calls(&log)
    );
    assert!(run.exists(), "so was the marker `omh s` reads");
    assert!(gitdir.exists(), "and the repository the refusal is about");
    assert!(worktree.exists());

    let out = sb.omh(&["s01", "rm", "--force"]);
    assert!(
        out.status.success(),
        "--force means it: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(!worktree.exists(), "the session is gone");
    // The repository is the thing the refusal was about, and the only test in
    // the tree where `rm` runs with a real one on disk. Left behind, the next
    // session issued this id adopts a dead session's history.
    assert!(
        !gitdir.exists(),
        "…and so is the sandbox repository it was protecting"
    );
}

/// An empty patch is a sentence, not a blank screen.
///
/// `Diff::human` exists partly to say *no changes on … (against …)*, because
/// silence reads as breakage. Handing the terminal straight to git skipped it,
/// and three quite different states then rendered identically: nothing
/// changed, the worktree had left its branch, and the pager was broken. The
/// first is the common one, so it is the one that made the other two look
/// survivable.
#[test]
fn a_patch_with_nothing_in_it_says_so_rather_than_showing_a_blank_screen() {
    let sb = sandbox();
    sb.session("s01");

    let out = sb.omh(&["s01", "diff", "-p"]);
    let printed = String::from_utf8_lossy(&out.stdout);

    assert!(out.status.success(), "an unchanged session is not an error");
    assert!(
        printed.contains("no changes"),
        "the reader is told which comparison came up empty: {printed:?}"
    );
}

/// Both routes to a patch refuse the same worktrees.
///
/// The paged path was written as a second copy of the unpaged one and dropped
/// the guard against a worktree that left its branch — so `omh sNN diff`
/// refused, naming the branch, and `omh sNN diff -p` printed an empty patch
/// and exited 0, one flag apart. Four reviewers found it independently.
///
/// Asserted as an agreement rather than as a second copy of the guard's
/// wording: what matters is that the two routes answer the same question about
/// whether there is an answer at all, which survives a third route being added.
#[test]
fn a_worktree_that_left_its_branch_is_refused_whichever_way_you_ask() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("feature.rs"), "fn added() {}\n").unwrap();

    let healthy: Vec<bool> = [vec!["s01", "diff"], vec!["s01", "diff", "-p"]]
        .iter()
        .map(|args| sb.omh(args).status.success())
        .collect();
    assert_eq!(healthy, vec![true, true], "both work on a healthy session");

    // Look at something else for a moment, the way a person does.
    let head = Command::new("git")
        .arg("-C")
        .arg(&worktree)
        .args(["rev-parse", "HEAD"])
        .output()
        .unwrap();
    let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
    let out = Command::new("git")
        .arg("-C")
        .arg(&worktree)
        .args(["checkout", "-q", "--detach", &head])
        .output()
        .unwrap();
    assert!(out.status.success(), "detaching the worktree: {out:?}");

    for args in [vec!["s01", "diff"], vec!["s01", "diff", "-p"]] {
        let out = sb.omh(&args);
        let said = String::from_utf8_lossy(&out.stderr);
        assert!(
            !out.status.success(),
            "`omh {}` handed over a review from a worktree that left its branch",
            args.join(" ")
        );
        assert!(
            said.contains("omh/s01"),
            "and the refusal names the branch: {said}"
        );
    }
}

/// `--base` alongside a checkpoint is refused rather than dropped.
///
/// A checkpoint is measured against its own parent, so a `--base` given with
/// one can only be ignored — and `omh s01 diff 4 --base v1.2` silently
/// answering about the parent is the resolve-by-quietly-dropping-one this
/// codebase refuses for `--new` and `--session`.
#[test]
fn a_base_given_with_a_checkpoint_is_refused() {
    let sb = sandbox();
    sb.session("s01");

    let out = sb.omh(&["s01", "diff", "4", "--base", "main"]);
    let said = String::from_utf8_lossy(&out.stderr);
    assert!(
        !out.status.success() && said.contains("--base"),
        "the flag cannot be honoured here and is not silently dropped: {said}"
    );
}

/// A never-launched sandbox answers `diff <n>` the way `log` answers.
///
/// `log` goes to some trouble to say *no checkpoints* and exit 0 for a session
/// whose sandbox has never run. `diff 1` on the same session used to reach
/// `seed()` and quote the path of a record the user has never heard of — two
/// commands one word apart, one of them speaking about omh's internals.
#[test]
fn a_checkpoint_asked_for_before_the_sandbox_ran_says_so_plainly() {
    let sb = sandbox();
    sb.session("s01");

    let out = sb.omh(&["s01", "diff", "1"]);
    let said = String::from_utf8_lossy(&out.stderr);

    assert!(!out.status.success(), "there is no checkpoint 1: {said}");
    assert!(
        said.contains("has not committed anything"),
        "and it says so in the words `log` uses: {said}"
    );
    assert!(
        !said.contains("seed"),
        "rather than naming a record the user has never heard of: {said}"
    );
}

/// The session named first is the session acted on — not the one omh would
/// have picked.
///
/// Two sessions, because with one the question cannot be asked: `pick` falls
/// back to the only session there is, so every assertion holds whether the
/// prefix works or is dropped on the floor. Both directions, because whichever
/// of the two `pick` prefers would otherwise carry a test that proves nothing.
#[test]
fn the_session_named_first_is_the_one_the_command_acts_on() {
    let sb = sandbox();
    let one = sb.session("s01");
    let two = sb.session("s02");
    std::fs::write(one.join("only-in-s01.rs"), "fn main() {}").unwrap();
    std::fs::write(two.join("only-in-s02.rs"), "fn main() {}").unwrap();

    for (named, mine, theirs) in [
        ("s01", "only-in-s01.rs", "only-in-s02.rs"),
        ("s02", "only-in-s02.rs", "only-in-s01.rs"),
    ] {
        let out = sb.omh(&[named, "diff"]);
        let printed = String::from_utf8_lossy(&out.stdout);
        assert!(
            printed.contains(mine) && !printed.contains(theirs),
            "`omh {named} diff` has to report {named}: {printed}{}",
            String::from_utf8_lossy(&out.stderr)
        );
    }
}

/// The spellings the prefix replaced are gone, not quietly still accepted.
///
/// A deletion nothing asserts is one a later change restores by accident — and
/// `diff` taking an id in two places is how the session came to have two
/// answers in the first place.
#[test]
fn the_spellings_the_prefix_replaced_are_refused() {
    let sb = sandbox();
    sb.session("s01");
    for line in [
        vec!["s", "diff", "s01"],
        vec!["s", "rm", "s01"],
        vec!["s", "down", "s01"],
        vec!["graph", "s01"],
    ] {
        let out = sb.omh(&line);
        let said = String::from_utf8_lossy(&out.stderr);
        // Refused *for naming it there*, not for some unrelated reason further
        // down — a test that only asks for a non-zero exit passes on the day
        // the command breaks for a different cause entirely. The refusal has
        // to quote the token, which is what makes it about that token; the
        // wording differs by slot and is not the invariant. `diff` says
        // *invalid value 's01' for '[CHECKPOINT]'* now that the slot takes a
        // checkpoint number, which is a better answer than the one this used
        // to pin.
        assert!(
            !out.status.success() && said.contains("'s01'"),
            "`omh {}` names the session where it no longer goes: {said}",
            line.join(" ")
        );
    }
}

/// `pick` invents the next id when none exists, which is right for a launch
/// about to create that worktree and wrong here. Reaching for it would make
/// this fail somewhere further down, about a path nobody named.
#[test]
fn committing_with_no_session_says_so_rather_than_inventing_one() {
    let sb = sandbox();

    let out = sb.omh(&["s", "commit", "-m", "anything"]);

    assert!(!out.status.success(), "there is nothing to commit to");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("no sessions"), "got: {err}");
}

/// Committing does not *stop* `s diff` reporting: the work is the same work
/// before and after, and a review that changed its answer at the moment of a
/// commit would be reporting the commit rather than the session.
#[test]
fn work_committed_from_the_host_is_what_diff_then_reports() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("feature.rs"), "fn main() {}").unwrap();

    let out = sb.omh(&["s", "commit", "-m", "Add the feature"]);
    assert!(
        out.status.success(),
        "commit failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let printed = String::from_utf8_lossy(&sb.omh(&["s01", "diff"]).stdout).to_string();
    assert!(printed.contains("feature.rs"), "got: {printed}");
}

/// The refusal is wired to the command, not just to a function that could
/// refuse.
///
/// The unit test decides *what* to say about markers; this asserts the command
/// asks at all. Worth its own case because the failure mode is a deleted line
/// rather than a wrong answer: `commit` would keep passing every other test
/// and quietly land a half-resolved merge on the branch.
#[test]
fn a_commit_over_conflict_markers_is_refused_by_the_command_itself() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(
        worktree.join("tap.rs"),
        "<<<<<<< main\nfn ours() {}\n=======\nfn theirs() {}\n>>>>>>> s01\n",
    )
    .unwrap();

    let out = sb.omh(&["s01", "commit", "-m", "Add the tap"]);
    assert!(!out.status.success(), "a half-resolved merge does not land");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("tap.rs:1"), "and it says where: {err}");

    let out = sb.omh(&["s01", "commit", "-m", "Add the tap", "--force"]);
    assert!(
        out.status.success(),
        "and the user can still mean it: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

/// `--keep` is the harvest, and it says so rather than pretending it committed.
///
/// A session whose sandbox never ran has no repository to keep anything from,
/// and the honest answer is "nothing to keep" — not a cheerful "committed" over
/// an empty branch, which is the report a user would act on.
#[test]
fn keeping_a_sessions_own_commits_says_so_when_there_are_none() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("feature.rs"), "fn main() {}").unwrap();

    let out = sb.omh(&["s", "commit", "--keep"]);

    let said = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        said.contains("no sandbox repository") || said.contains("nothing to keep"),
        "a session with no sandbox repository has nothing to keep, and has to \
         say which: {said}"
    );
    assert!(
        !said.contains("committed to"),
        "and must not report a commit it did not make: {said}"
    );
}

/// `-m` and `--keep` are two ways to land the same work and must not both run:
/// the squash lands the content first, and git's patch-id then drops every
/// replanted commit as already applied — the granular history `--keep` exists
/// to deliver, gone with nothing said.
#[test]
fn a_message_and_keeping_the_agents_commits_are_refused_together() {
    let sb = sandbox();
    sb.session("s01");

    let out = sb.omh(&["s", "commit", "-m", "squashed", "--keep"]);

    assert!(!out.status.success());
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("cannot be used with"), "got: {err}");
}

/// The agent cannot commit — that is the whole shape of a session — so the
/// window in which `s diff` is the *only* way to see the work is the entire
/// time the agent is running. It reported an empty diff for all of it, while
/// the rules omh ships told the agent the user reviews before committing.
///
/// End to end rather than against `Session::diff`, because the unit tests
/// would stay green if `s diff` stopped reaching it.
#[test]
fn diff_reports_a_sessions_work_before_anyone_commits_it() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("feature.rs"), "fn main() {}").unwrap();

    let out = sb.omh(&["s01", "diff"]);

    assert!(
        out.status.success(),
        "diff failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let printed = String::from_utf8_lossy(&out.stdout);
    assert!(
        printed.contains("feature.rs"),
        "uncommitted work is the only work there is yet: {printed}"
    );
}

/// A session id is a path component and `Session::new` joins it into the
/// worktree path. `s rm` already validates; so must anything else that takes
/// one from the command line.
///
/// Asserting the *reason*, not just the failure: a missing worktree fails this
/// too, so a bare `!success` here stays green with the validation deleted —
/// confirmed by deleting it.
#[test]
fn a_session_id_that_is_a_path_is_refused() {
    let sb = sandbox();
    sb.session("s01");

    let out = sb.omh(&["-s", "../escape", "s", "commit", "-m", "x"]);

    assert!(!out.status.success(), "`../escape` is not a session id");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.contains("not a path"),
        "refused for the wrong reason: {err}"
    );
}

/// A committed session with no upstream has everything to push and nothing to
/// compare against. Without the base-branch fallback that prints a blank —
/// indistinguishable from a session nobody has touched — so measuring against
/// the base is what makes "never report work as clean" true in the state the
/// loop passes through every time.
#[test]
fn a_session_that_has_committed_but_never_pushed_is_not_reported_as_clean() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("feature.rs"), "fn main() {}").unwrap();
    assert!(sb
        .omh(&["s", "commit", "-m", "Add the feature"])
        .status
        .success());

    let printed = String::from_utf8_lossy(&sb.omh(&["s"]).stdout).to_string();

    assert!(printed.contains("to push"), "got: {printed}");
}

/// `omh s` is where every one of these measurements is actually read, and the
/// rendering is the part no unit test reaches. Each state is one the loop sits
/// in, not one it passes through, so a blank column is a wrong answer rather
/// than a missing one.
#[test]
fn the_listing_renders_each_state_a_session_can_sit_in() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    let ls = || String::from_utf8_lossy(&sb.omh(&["s"]).stdout).to_string();

    std::fs::write(worktree.join("a.rs"), "fn a() {}").unwrap();
    assert!(ls().contains("1 uncommitted"), "got: {}", ls());

    assert!(sb.omh(&["s", "commit", "-m", "Add a"]).status.success());
    assert!(ls().contains("1 to push"), "got: {}", ls());

    assert!(sb.omh(&["s", "push", "feat/a"]).status.success());
    assert!(ls().contains("→ feat/a"), "got: {}", ls());
}

/// The worktree's `.git` is a pointer at an absolute path, and a checkout that
/// moves leaves it dangling — a state `Session::remove` already treats as real.
/// Every accessor then fails, and defaulting them to zero renders a session
/// holding a day of work as clean, which is what leads someone to `s rm` it.
#[test]
fn a_session_omh_cannot_read_is_never_rendered_as_clean() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("a.rs"), "fn a() {}").unwrap();
    // Break the pointer the way a moved or re-cloned checkout would.
    std::fs::write(worktree.join(".git"), "gitdir: /nowhere/that/exists").unwrap();

    let printed = String::from_utf8_lossy(&sb.omh(&["s"]).stdout).to_string();

    assert!(
        printed.contains("s01"),
        "the session is still listed: {printed}"
    );
    assert!(
        printed.contains('?'),
        "omh cannot tell, and must say so rather than imply clean: {printed}"
    );
}

/// `omh s push <name>` has to carry the name through the CLI, and `--pr` has to
/// treat `gh`'s exit code as the answer it is. Both are wiring no unit test on
/// `Session::push` can reach.
#[test]
fn the_push_command_carries_its_name_and_refuses_without_one() {
    let sb = sandbox();
    let worktree = sb.session("s01");
    std::fs::write(worktree.join("a.rs"), "fn a() {}").unwrap();
    assert!(sb.omh(&["s", "commit", "-m", "Add a"]).status.success());

    let bare = sb.omh(&["s", "push"]);
    assert!(!bare.status.success(), "a session id is not a branch name");
    assert!(String::from_utf8_lossy(&bare.stderr).contains("not a branch name"));

    assert!(sb.omh(&["s", "push", "feat/a"]).status.success());
    let printed = String::from_utf8_lossy(&sb.omh(&["s", "push", "feat/a"]).stdout).to_string();
    assert!(printed.contains("origin/feat/a"), "got: {printed}");
}

/// `existing_session` refuses an id with no worktree so the failure names the
/// session rather than arriving from inside git, about a path nobody chose.
#[test]
fn a_session_that_does_not_exist_is_named_in_the_refusal() {
    let sb = sandbox();
    sb.session("s01");

    let out = sb.omh(&["-s", "s99", "s", "commit", "-m", "x"]);

    assert!(!out.status.success());
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("s99"), "the refusal must name it: {err}");
}

/// The launcher discloses this repo's hooks, and a dry run leaves no trace.
///
/// `notice::hooks` and `Record::commit` are both well covered by unit tests,
/// and the wire between them and `run()` is not: deleting the `say_hooks` call
/// entirely, or committing the snapshot on a dry run, leaves the whole suite
/// green. That is the failure this file's module doc says it exists to notice,
/// and it is the same shape as `own.mcp_env = settings.mcp_env` was.
///
/// The snapshot's *absence* is what makes the second half checkable without a
/// container: a dry run that recorded would spend the one call-out about
/// somebody else's executable content changing under you, and the next real
/// launch would be silent.
///
/// `#[ignore]`d because it needs git and a container runtime to reach `run()`.
/// CI's linux job runs `--include-ignored`, which is where this bites.
#[test]
#[ignore]
fn a_dry_run_discloses_the_repos_hooks_and_records_nothing() {
    let sb = sandbox();
    sb.git_init();
    std::fs::write(sb.repo.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
    assert!(
        sb.omh(&["init"]).status.success(),
        "init must set the repo up"
    );
    std::fs::write(
        sb.repo.join(".omh/hooks/rust-test.json"),
        r#"{ "on": "turn-end", "run": "cargo test" }"#,
    )
    .unwrap();

    let out = sb.omh(&["--dry-run", "claude"]);
    let said = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        said.contains("this repo's hooks") && said.contains("rust-test"),
        "a launch has to name the executable content it was handed: {said}"
    );

    let snapshot = sb
        .home
        .join(".omh/run")
        .join(sb.repo.file_name().unwrap())
        .join("hooks.json");
    assert!(
        !snapshot.exists(),
        "a dry run recorded {} — the next real launch would be silent about a change",
        snapshot.display()
    );
}

/// The launcher says what this repo is *not* using from your catalogue.
///
/// Same wire, same gap: `notice::selection` and `Selection::unselected` are both
/// covered, and deleting the `say_selection` call leaves every one of those
/// tests green. It is the report that makes an expanded `[use]` safe — `init`
/// writes the list once and never revisits it, so without this a skill added
/// afterwards is off and nothing about the repo says why.
///
/// `#[ignore]`d because it needs git and a container runtime to reach `run()`.
/// CI's linux job runs `--include-ignored`, which is where this bites.
#[test]
#[ignore]
fn a_dry_run_names_the_catalogue_entries_this_repo_is_not_using() {
    let sb = sandbox();
    sb.git_init();
    assert!(
        sb.omh(&["init"]).status.success(),
        "init must set the repo up"
    );
    // Added to the catalogue *after* init wrote the list, which is the whole
    // case: the entry is off, and the reason is invisible without this report.
    std::fs::create_dir_all(sb.home.join(".omh/skills/refactor")).unwrap();
    std::fs::write(sb.home.join(".omh/skills/refactor/SKILL.md"), "x").unwrap();

    let said = String::from_utf8_lossy(&sb.omh(&["--dry-run", "claude"]).stderr).to_string();
    assert!(
        said.contains("skills/refactor"),
        "a launch has to name what it is not doing: {said}"
    );
    assert!(
        said.contains("omh use skills refactor"),
        "and the command that fixes it: {said}"
    );
}

// ── selection, and the two scopes ───────────────────────────────────────────

/// `omh use` writes the **committed** file. What a project uses is a fact about
/// the project, and a teammate cloning it should get the same selection — the
/// opposite default from `omh repo set`, which holds `carry_in` paths and MCP
/// env and must not be committable by accident. One flag could not express both,
/// which is why `--layer` split into two commands.
#[test]
fn use_writes_the_committed_file_and_unuse_takes_a_name_back_out() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/review-diff/SKILL.md", "skills/refactor/SKILL.md"]);

    assert!(sb.omh(&["use", "skills", "review-diff"]).status.success());
    let written = sb.settings();
    assert!(written.contains("review-diff"), "got: {written}");
    assert!(
        written.contains("refactor"),
        "a capability that was following the whole catalogue must not be \
         narrowed to one name by adding one: {written}"
    );
    assert!(
        !sb.repo.join(".omh/settings.local.toml").exists(),
        "the gitignored file is `omh repo set`'s, not this command's"
    );

    assert!(sb.omh(&["unuse", "skills", "refactor"]).status.success());
    let written = sb.settings();
    assert!(written.contains("review-diff"), "got: {written}");
    assert!(!written.contains("refactor"), "taken back out: {written}");
}

/// Selecting something already selected is not a write and not an error.
#[test]
fn use_is_idempotent_and_unuse_refuses_a_name_this_repo_never_used() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/review-diff/SKILL.md"]);
    sb.omh(&["use", "skills", "review-diff"]);

    // The invariant, not the message: "already used" is what it says, and
    // "not a write" is what it means. Asserting the sentence left a mutation
    // that writes the list back before printing it entirely green.
    let before = sb.settings();
    let out = sb.omh(&["use", "skills", "review-diff"]);
    assert!(out.status.success());
    assert!(String::from_utf8_lossy(&out.stdout).contains("already used"));
    assert_eq!(sb.settings(), before, "selecting it again touched the file");

    // Refused rather than written as a no-op: a name this repo never used is a
    // typo, and writing the list back would report success for it.
    let out = sb.omh(&["unuse", "skills", "nosuchthing"]);
    assert!(!out.status.success(), "a typo must not report success");
    assert!(String::from_utf8_lossy(&out.stderr).contains("nosuchthing"));
}

/// `[use]` names *your* entries; a feature is `[omh]`'s business, and the CLI
/// has to teach that rather than leave it in the docs.
#[test]
fn use_refuses_a_feature_and_disable_refuses_an_entry() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/review-diff/SKILL.md"]);

    let out = sb.omh(&["use", "mcp", "codegraph"]);
    assert!(!out.status.success(), "codegraph is omh's, not yours");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("codegraph"), "must name it: {err}");
    assert!(
        err.contains("omh repo disable codegraph"),
        "and point at the switch that does work: {err}"
    );

    // And the other direction, so the distinction is not one-way.
    let out = sb.omh(&["repo", "disable", "review-diff"]);
    assert!(!out.status.success(), "a skill is not a feature");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("omh use"), "point back the other way: {err}");
}

/// `omh repo disable` writes `[omh]` in the committed file, and says plainly
/// that nothing was uninstalled — the distinction the whole feature rests on.
#[test]
fn repo_disable_switches_a_feature_off_here_without_uninstalling_it() {
    let sb = sandbox();
    sb.seed_base();

    let out = sb.omh(&["repo", "disable", "codegraph"]);
    assert!(
        out.status.success(),
        "{:?}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        sb.settings().contains("codegraph = false"),
        "{}",
        sb.settings()
    );
    assert!(String::from_utf8_lossy(&out.stdout).contains("nothing was uninstalled"));

    assert!(sb.omh(&["repo", "enable", "codegraph"]).status.success());
    assert!(
        sb.settings().contains("codegraph = true"),
        "{}",
        sb.settings()
    );
}

/// The two opposite defaults, side by side. `omh repo set` must not be able to
/// put a token in a file git will commit unless asked in so many words.
#[test]
fn repo_set_is_gitignored_and_shared_says_it_is_not() {
    let sb = sandbox();
    sb.seed_base();

    assert!(sb
        .omh(&["repo", "set", "carry_in", "[\".env\"]"])
        .status
        .success());
    let local = std::fs::read_to_string(sb.repo.join(".omh/settings.local.toml")).unwrap();
    assert!(local.contains(".env"), "got: {local}");

    let out = sb.omh(&["repo", "set", "--shared", "idle_timeout", "30m"]);
    assert!(out.status.success());
    assert!(sb.settings().contains("30m"), "{}", sb.settings());
    // On **stderr**, and that is the stronger place for it. This warning is
    // the last thing standing between somebody and a token in git history,
    // and the invocation where that actually happens is the scripted one —
    // `omh repo set --shared … > log`, where anything on stdout goes to the
    // file unread. stderr is the stream that still reaches a person there.
    assert!(
        String::from_utf8_lossy(&out.stderr).contains("COMMITTED"),
        "writing the committed file has to say so, where a redirect cannot hide it: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        !String::from_utf8_lossy(&out.stdout).contains("COMMITTED"),
        "and it is a diagnostic, so it does not land in the answer"
    );
}

/// `omh config set` means **you** now. It used to default to the repo's
/// gitignored file; the secret-safety argument survives intact, because the
/// personal file is not committed either.
#[test]
fn config_set_writes_your_defaults() {
    let sb = sandbox();
    sb.seed_base();
    assert!(sb
        .omh(&["config", "set", "idle_timeout", "45m"])
        .status
        .success());
    let personal = std::fs::read_to_string(sb.home.join(".omh/settings.toml")).unwrap();
    assert!(personal.contains("45m"), "got: {personal}");
    assert!(
        !sb.repo.join(".omh/settings.local.toml").exists(),
        "this is not a repo-scoped command any more"
    );
}

/// `--layer` keeps working for one release and says what replaced it. A flag
/// that outlives its documentation is how people learn a form that is about to
/// stop existing; a hard error would cost more than it protects, since this one
/// is recoverable by retyping.
#[test]
fn layer_still_works_and_names_what_replaced_it() {
    let sb = sandbox();
    sb.seed_base();
    let out = sb.omh(&["config", "set", "--layer", "shared", "idle_timeout", "1h"]);
    assert!(out.status.success(), "still works");
    assert!(sb.settings().contains("1h"), "and writes where it said");
    let said = String::from_utf8_lossy(&out.stderr);
    assert!(said.contains("going away"), "got: {said}");
    assert!(
        said.contains("omh repo set --shared"),
        "and names the form that replaces it: {said}"
    );
}

/// A name is checked where it is minted, so `edit` cannot be talked into
/// joining a path to the catalogue directory.
#[test]
fn edit_refuses_a_name_that_climbs_out_of_the_catalogue() {
    let sb = sandbox();
    sb.seed_base();
    let out = sb.omh(&["config", "edit", "skills", "../../../.ssh/id_rsa"]);
    assert!(!out.status.success(), "traversal must not reach $EDITOR");
    assert!(String::from_utf8_lossy(&out.stderr).contains("never a path"));
}

/// Bare `omh repo` is where the reporting this design keeps promising surfaces:
/// with a curated list the useful question stops being "what is this set to" and
/// becomes "why is this skill not here".
#[test]
fn bare_repo_reports_what_is_used_what_is_not_and_what_decided_it() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/review-diff/SKILL.md", "skills/refactor/SKILL.md"]);
    sb.omh(&["use", "skills", "review-diff"]);
    sb.omh(&["unuse", "skills", "refactor"]);
    sb.omh(&["repo", "disable", "codegraph"]);
    sb.omh(&["repo", "set", "carry_in", "[\".env\"]"]);

    let out = sb.omh(&["repo"]);
    assert!(
        out.status.success(),
        "{:?}",
        String::from_utf8_lossy(&out.stderr)
    );
    let said = String::from_utf8_lossy(&out.stdout);
    assert!(said.contains("review-diff"), "what is used: {said}");
    assert!(said.contains("refactor"), "and what is not: {said}");
    assert!(said.contains("codegraph"), "omh's features: {said}");
    assert!(said.contains("off here"), "and their state: {said}");
    assert!(said.contains("carry_in"), "settings: {said}");
    assert!(
        said.contains("local"),
        "and which file decided each: {said}"
    );
}

/// A settings file is a file somebody maintains by hand, and comments are part
/// of what they wrote.
///
/// Before P4 a write to `.omh/settings.toml` was rare — `omh config set` and
/// nothing else. Now `omh use`, `omh unuse` and `omh repo enable` all touch it,
/// and `init` writes it *full* of explanatory comments, so a round trip through
/// a serializer would have the first `omh use` silently delete everything init
/// had just explained. That is data loss, not formatting.
#[test]
fn writing_a_setting_keeps_what_you_wrote_around_it() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/review-diff/SKILL.md"]);
    std::fs::create_dir_all(sb.repo.join(".omh")).unwrap();
    std::fs::write(
        sb.repo.join(".omh/settings.toml"),
        "# why this repo carries an env file\ncarry_in = [\".env.local\"]  # the app needs it\n",
    )
    .unwrap();

    assert!(sb.omh(&["use", "skills", "review-diff"]).status.success());

    let after = sb.settings();
    assert!(
        after.contains("# why this repo carries an env file"),
        "the comment above a setting is part of the setting: {after}"
    );
    assert!(
        after.contains("# the app needs it"),
        "and so is the one beside it: {after}"
    );
    assert!(
        after.contains("review-diff"),
        "and the write happened: {after}"
    );
}

/// Everything this repo ships as data reaches `~/.omh`, where the code reads it.
///
/// The failure this catches has already happened twice in this project, and
/// `bundled.rs` opens by describing it: a guard is correct while the wiring that
/// reaches it is missing, and the suite stays green. `bundled`'s own tests
/// iterate what is *embedded*, so a directory absent from `build.rs`'s `SHIPPED`
/// is neither embedded nor noticed — the guard is structurally blind to exactly
/// the mistake of forgetting to add one.
///
/// Asserted against the repository's own directories rather than a list written
/// here, so a fifth kind is covered the day somebody adds it.
///
/// Runs anywhere: `init` seeds these before it needs a container, so it does not
/// matter that it fails later for want of one.
#[test]
fn init_installs_every_directory_this_repo_ships() {
    let sb = sandbox();
    std::fs::write(sb.repo.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();

    let out = Command::new(env!("CARGO_BIN_EXE_omh"))
        .arg("init")
        .current_dir(&sb.repo)
        .env("HOME", &sb.home)
        .env("PATH", "/nonexistent")
        .output()
        .expect("the binary under test must run");

    let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"));
    for kind in ["adapters", "base", "editors", "stacks"] {
        let src = repo_root.join(kind);
        let shipped: Vec<String> = std::fs::read_dir(&src)
            .unwrap_or_else(|e| panic!("this repo must ship {kind}: {e}"))
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .filter(|n| n.ends_with(".toml"))
            .collect();
        assert!(!shipped.is_empty(), "{kind} ships nothing");

        for name in shipped {
            let landed = sb.home.join(".omh").join(kind).join(&name);
            assert!(
                landed.exists(),
                "{kind}/{name} is in the repo and not in ~/.omh — embedded but \
                 never installed, or never embedded. stdout: {} stderr: {}",
                String::from_utf8_lossy(&out.stdout),
                String::from_utf8_lossy(&out.stderr)
            );
        }
    }
}

/// Provisioning stays behind the runtime check, and records nothing without it.
///
/// This is an **ordering** guard, not the cannot-tell one — with no runtime
/// `init` never reaches the provisioning block, so it would pass even if
/// `fired_from` lost its empty-answer branch. That rule is guarded where it can
/// actually be exercised, by `a_resolution_nobody_measured_is_never_recorded`.
/// What this catches is provisioning being moved *above* `runtime::select`,
/// which would write `[provision]` on a box that never asked a sandbox
/// anything — and since `stack::reconcile` drops every `true` it is not told
/// about, that write would erase the table rather than add to it.
///
/// Runs anywhere, because "no container runtime" is the condition under test.
#[test]
fn a_missing_container_runtime_records_no_resolution() {
    let sb = sandbox();
    std::fs::write(sb.repo.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();

    let out = Command::new(env!("CARGO_BIN_EXE_omh"))
        .arg("init")
        .current_dir(&sb.repo)
        .env("HOME", &sb.home)
        .env("PATH", "/nonexistent")
        .output()
        .expect("the binary under test must run");

    let settings = sb.settings();
    assert!(
        !settings.contains("[provision]"),
        "an unmeasured resolution must not be written: {settings}\nstderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // And the repo is still configured — the container work stays last.
    assert!(settings.contains("[use]"), "got: {settings}");
}

/// Setting a repo up must not be abandoned half-done because the machine has no
/// container runtime.
///
/// The image build moved above the rest of `init` so a toolchain could be probed
/// before hooks were seeded. It propagates, so on a box with neither docker nor
/// sbx — somebody installing omh before a runtime, which is the first thing they
/// would do — `init` now bails after writing hooks and **before**
/// `config::write_selection` and before gitignoring `settings.local.toml`.
///
/// The second of those is the one that bites quietly: a `settings.local.toml`
/// left tracked is how a machine-local override reaches the team's repo, which
/// is the whole reason that line is written at all.
///
/// Runs anywhere, because "no container runtime" is the condition under test.
#[test]
fn a_missing_container_runtime_still_leaves_the_repo_configured() {
    let sb = sandbox();
    sb.git_init();
    std::fs::write(sb.repo.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
    sb.catalogue(&["skills/review-diff/SKILL.md"]);

    // Nothing on PATH, so `runtime::select` can find no backend. Whether init
    // reports failure is not what is asserted — what it left behind is.
    let out = Command::new(env!("CARGO_BIN_EXE_omh"))
        .arg("init")
        .current_dir(&sb.repo)
        .env("HOME", &sb.home)
        .env("PATH", "/nonexistent")
        .output()
        .expect("the binary under test must run");

    let gitignore = std::fs::read_to_string(sb.repo.join(".omh/.gitignore")).unwrap_or_default();
    assert!(
        gitignore.contains("settings.local.toml"),
        "a tracked settings.local.toml is how a machine-local override gets \
         committed to the team's repo. stdout: {} stderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        sb.settings().contains("[use]"),
        "and the selection is what switches this repo's own hooks on: {}",
        sb.settings()
    );
}

/// `omh init` writes the selection out with every entry named.
///
/// Expanded rather than `"*"`, because an explicit list is editable and
/// reviewable in a way a wildcard is not — you curate by deleting lines. The
/// repo's own detected hooks are in it, because `init` wrote those a moment
/// earlier and a list that omitted them would switch off what init just
/// created; omh's own are not, because `[omh]` governs those and `[use]`
/// refuses to name one.
///
/// Both halves are asserted, because they are different claims. The first is
/// about what `init` wrote into `<repo>/.omh/hooks/` — the derived hooks, the
/// ones only this project could want — and it is checked against the directory
/// rather than against a spelling, so a hook added there later inherits it.
///
/// The second is about the conventional ones, which since hooks were separated
/// from stacks live in the **catalogue**: `cargo test` is what a rust project
/// runs, not what *this* rust project runs, so one body per ecosystem is the
/// honest scope. They reach a launch by being named in `[use]`, so that is
/// where this asserts they are — unconditionally, because `[toolchain]` governs
/// whether a hook *runs* here and never whether it is selected, which is why
/// this holds on an image with no rust in it. Both directions of the ecosystem
/// filter are checked: the loop above would pass just as happily on a selection
/// that named every ecosystem omh ships as on one that named none.
///
/// `#[ignore]`d because `init` builds an image, so it needs a container runtime.
/// CI's linux job runs `--include-ignored`, which is where this bites.
#[test]
#[ignore]
fn init_writes_the_selection_expanded() {
    let sb = sandbox();
    sb.git_init();
    std::fs::write(sb.repo.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
    sb.catalogue(&["skills/review-diff/SKILL.md"]);
    assert!(sb.omh(&["init"]).status.success());

    let written = sb.settings();
    assert!(written.contains("[use]"), "got: {written}");
    assert!(written.contains("review-diff"), "your catalogue: {written}");

    // Everything init wrote is named in the list init then wrote. A hook on
    // disk and absent from `[use]` is a hook switched off by the same run that
    // created it.
    let hooks: Vec<String> = std::fs::read_dir(sb.repo.join(".omh/hooks"))
        .expect("init creates the hooks directory")
        .flatten()
        .map(|e| {
            e.file_name()
                .to_string_lossy()
                .trim_end_matches(".json")
                .to_string()
        })
        .collect();
    for h in &hooks {
        assert!(
            written.contains(h.as_str()),
            "init wrote {h} and then left it out of the selection: {written}"
        );
    }
    // And not vacuously. The detected stack's hooks are written whatever this
    // machine's image holds — the file is the repo's, and `[toolchain]` decides
    // whether it *runs*, never whether it exists. An empty directory would
    // satisfy the loop above while meaning init had stopped writing hooks.
    assert!(
        written.contains("\"rust-test\"") && written.contains("\"rust-format\""),
        "the detected stack's hooks are unconditional: {written}"
    );
    assert!(
        !written.contains("go-test") && !written.contains("python-test"),
        "and an ecosystem this repo is not must not be selected into it: {written}"
    );
    assert!(
        !written.contains("codegraph") || !written.contains("mcp = [\"codegraph"),
        "omh's own are `[omh]`'s, not `[use]`'s: {written}"
    );
    // The comment block init writes is what explains the file. A selection
    // appended by a serializer round trip would have deleted all of it.
    assert!(
        written.contains("# carry_in"),
        "init's own explanation has to survive its own write: {written}"
    );

    // Re-running must not resync a list somebody pruned on purpose.
    assert!(sb.omh(&["unuse", "skills", "review-diff"]).status.success());
    assert!(sb.omh(&["init"]).status.success());
    assert!(
        !sb.settings().contains("review-diff"),
        "init writes the list once; `omh use --all` is how you ask for a resync"
    );
}

/// A command that removes something has to remove it.
///
/// `omh use` and `omh unuse` write the committed file, but the selection is
/// resolved across all three settings files with the gitignored one last and
/// winning. So a `[use]` in `settings.local.toml` made `omh unuse` write
/// correctly, report success, and change nothing the session could see — the
/// shape the invariant table is built around ("nothing to commit is never a
/// successful commit").
///
/// Both files are written when both declare it. Refusing was the other option
/// and it is worse: the local table is usually there on purpose, and a command
/// that will not act until you delete it teaches people to stop using it.
#[test]
fn use_writes_every_repo_layer_that_already_declares_the_capability() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/review-diff/SKILL.md", "skills/refactor/SKILL.md"]);
    std::fs::create_dir_all(sb.repo.join(".omh")).unwrap();
    std::fs::write(
        sb.repo.join(".omh/settings.local.toml"),
        "[use]\nskills = [\"review-diff\", \"refactor\"]\n",
    )
    .unwrap();

    let out = sb.omh(&["unuse", "skills", "refactor"]);
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );

    let local = std::fs::read_to_string(sb.repo.join(".omh/settings.local.toml")).unwrap();
    assert!(
        !local.contains("refactor"),
        "the layer that decides has to be the layer that changed: {local}"
    );
    assert!(
        local.contains("review-diff"),
        "and only that name went: {local}"
    );
    assert!(
        sb.settings().contains("review-diff") && !sb.settings().contains("refactor"),
        "the committed file is still the one a teammate gets: {}",
        sb.settings()
    );
    assert!(
        String::from_utf8_lossy(&out.stdout).contains("settings.local.toml"),
        "and it says both files were written: {}",
        String::from_utf8_lossy(&out.stdout)
    );
}

/// The other half: a local file that says nothing about this capability must
/// not acquire a `[use]` table because a committed one was edited. A selection
/// silently appearing in a gitignored file is how a teammate stops getting what
/// the repo says it uses.
#[test]
fn a_local_file_that_declares_nothing_stays_that_way() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/review-diff/SKILL.md"]);
    std::fs::create_dir_all(sb.repo.join(".omh")).unwrap();
    std::fs::write(
        sb.repo.join(".omh/settings.local.toml"),
        "carry_in = [\".env\"]\n",
    )
    .unwrap();

    assert!(sb.omh(&["use", "skills", "review-diff"]).status.success());
    let local = std::fs::read_to_string(sb.repo.join(".omh/settings.local.toml")).unwrap();
    assert!(
        !local.contains("[use]"),
        "nothing was declared there: {local}"
    );
}

/// `[omh]` layers the same way, so `omh repo enable` has the same hole.
#[test]
fn a_feature_switch_reaches_the_layer_that_decides() {
    let sb = sandbox();
    sb.seed_base();
    std::fs::create_dir_all(sb.repo.join(".omh")).unwrap();
    std::fs::write(
        sb.repo.join(".omh/settings.local.toml"),
        "[omh]\ncodegraph = false\n",
    )
    .unwrap();

    assert!(sb.omh(&["repo", "enable", "codegraph"]).status.success());
    let local = std::fs::read_to_string(sb.repo.join(".omh/settings.local.toml")).unwrap();
    assert!(
        local.contains("codegraph = true"),
        "the local switch is what decides, so it is what has to move: {local}"
    );
}

/// Regression, seen live: `omh rm s01` deleted the worktree and left the
/// session container running. The next launch recreated the directory — a new
/// inode the running container's bind mount does not follow — and `session_up`,
/// seeing a container that was up, execed into it. Docker answered
///
///   OCI runtime exec failed: ... current working directory is outside of
///   container mount namespace root -- possible container breakout detected
///
/// on every command, forever: nothing in omh ever tears that container down, so
/// the session id stayed bricked until the user ran `docker rm -f` by hand.
///
/// A session *is* the container plus the worktree. Removing half of it is what
/// created a half that cannot be reached.
#[test]
fn rm_takes_the_session_container_down_with_the_worktree() {
    let sb = sandbox();
    let log = sb.fake_docker();
    let worktree = sb.home.join(".omh/worktrees/repo/s01");
    std::fs::create_dir_all(&worktree).unwrap();
    let run = sb.home.join(".omh/run/repo/s01");
    std::fs::create_dir_all(&run).unwrap();

    let out = sb.omh(&["s01", "rm"]);
    assert!(
        out.status.success(),
        "rm failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(!worktree.exists(), "the worktree must be gone");
    assert!(
        !run.exists(),
        "the session's staging and its last-used marker outlived it"
    );

    let calls = sb.docker_calls(&log);
    assert!(
        calls
            .iter()
            .any(|c| c.starts_with("rm ") && c.contains("omh-repo-s01")),
        "the container outlived the worktree it mounts: {calls:?}"
    );
}

/// The same half-removed state, seen from the other side. Nothing cleaned up
/// after `s rm` before this, so every removed session left a run directory and
/// often a container — and neither is visible from any command. The container
/// is the one that matters: an orphan holding a session id is what produced the
/// mount-namespace failure in the first place.
///
/// `doctor` and `auth` stage into the same tree under their own names and are
/// not sessions anybody can resume, so the marker `idle::touch` writes is what
/// separates a session that ran from scratch staging that never was one.
/// The everyday path, end to end: work is on the branch, the session goes, and
/// the branch stays for review.
///
/// `Session::remove`'s decision is pinned by unit tests. What this pins is that
/// the command wired to it does not delete the branch and reports the count
/// that decided the outcome — the number now travels *with* the outcome rather
/// than being asked for a second time.
///
/// Asserted against git and the JSON document rather than the sentence: the
/// prose may be reworded, the branch may not go missing.
#[test]
fn removing_a_session_that_committed_keeps_the_branch_for_review() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    let worktree = sb.session("s01");

    std::fs::write(worktree.join("work.txt"), "agent output").unwrap();
    for args in [vec!["add", "-A"], vec!["commit", "-q", "-m", "agent work"]] {
        let out = Command::new("git")
            .arg("-C")
            .arg(&worktree)
            .args(&args)
            .output()
            .expect("git must be installed to run this test");
        assert!(out.status.success(), "git {args:?}: {out:?}");
    }

    let out = sb.omh(&["s01", "rm", "--json"]);
    assert!(
        out.status.success(),
        "rm failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let alive = Command::new("git")
        .arg("-C")
        .arg(&sb.repo)
        .args(["rev-parse", "--verify", "omh/s01"])
        .output()
        .unwrap();
    assert!(
        alive.status.success(),
        "unreviewed work must outlive the session that made it"
    );

    let doc: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("`--json` is one document");
    assert_eq!(doc["branch_kept"], serde_json::json!(true));
    assert_eq!(
        doc["commits"],
        serde_json::json!(1),
        "and the count reported is the one that decided it"
    );
}

/// `omh s01` is one row of the dashboard, not a refusal and not a menu.
///
/// The prefix means *scope this to s01* for every other verb, so the no-verb
/// case is that same rule reaching the last place it had not. It used to be a
/// clap error, because `omh s` required a subcommand and `omh s01 ls` — the
/// only spelling that could have meant this — was refused outright.
///
/// A verb list would have been the alternative and is the wrong answer: the
/// user named a session, and replying with a menu throws that away.
#[test]
fn a_session_named_on_its_own_is_that_session_alone() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    sb.session("s01");
    sb.session("s02");

    let focused = sb.omh(&["s01"]);
    let printed = String::from_utf8_lossy(&focused.stdout);
    assert!(
        focused.status.success(),
        "a session named on its own is a question, not an error: {}",
        String::from_utf8_lossy(&focused.stderr)
    );
    assert!(printed.contains("s01"), "it is about s01: {printed}");
    assert!(
        !printed.contains("s02"),
        "and only about s01 — a focus that quietly widens is the thing the \
         selector exists to remove: {printed}"
    );
    // The invariant is not *no other id ever appears*: an unreadable session
    // is named in a focused view on purpose, because it is why the overlap
    // answer may be short a line. What must not appear is another session's
    // row, and that is what this fixture — two readable sessions — pins.

    // …and the unfocused command still answers about all of them.
    let all = String::from_utf8_lossy(&sb.omh(&["s"]).stdout).to_string();
    assert!(
        all.contains("s01") && all.contains("s02"),
        "`omh s` is still every session: {all}"
    );

    // A collision between two *other* sessions does not follow the focus in.
    // The one involving s01 does — that half is asserted where the overlap
    // section is tested, since it needs the fixture that produces one.
    let three = sb.session("s03");
    let four = sb.session("s04");
    for worktree in [&three, &four] {
        std::fs::write(worktree.join("elsewhere.rs"), "fn elsewhere() {}\n").unwrap();
    }
    let focused = String::from_utf8_lossy(&sb.omh(&["s01"]).stdout).to_string();
    assert!(
        !focused.contains("elsewhere.rs"),
        "a collision between two other sessions is not s01's business: {focused}"
    );

    // An id nothing created fails the way it fails for every other verb,
    // rather than listing nothing and looking like an answer.
    let missing = sb.omh(&["s99"]);
    let err = String::from_utf8_lossy(&missing.stderr).to_string();
    assert!(!missing.status.success(), "an unknown session is an error");
    // Checking only the exit code would accept a clap error, a panic, or a
    // failure from any layer at all — the comment in `sessions_ls` claims it
    // fails *the way every other verb fails*, so the message is the claim.
    assert!(
        err.contains("s99") && err.contains("omh s"),
        "the refusal names the id and where the real ones are listed: {err}"
    );
}

/// The focused listing's `--json` is one session, and says which.
///
/// `--json` is the scripting contract and returns before the asides, so the
/// document is the whole of what a script gets. The human filter and the JSON
/// filter share one binding today, which means a focus-drop mutation dies on
/// the human assertion — but that shared binding is an implementation detail,
/// and the contract should be pinned where it is read.
#[test]
fn the_focused_listing_is_one_session_in_the_document_too() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    sb.session("s01");
    sb.session("s02");

    let out = sb.omh(&["s01", "--json"]);
    let doc: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("`omh s01 --json` is a document");
    let sessions = doc["sessions"].as_array().expect("sessions is an array");
    assert_eq!(sessions.len(), 1, "one session was asked for: {doc}");
    assert_eq!(sessions[0]["id"], "s01", "and it is the one named: {doc}");
}

/// A verb that was retired is refused by name, and never becomes another
/// command.
///
/// The `ls` verb was the documented spelling until 2026.08, so it is in muscle
/// memory and in scripts. Retiring it left two ways to get this wrong, and
/// only one of them is harmless.
///
/// Typing it bare is: clap rejects an unknown subcommand. `omh s01 ls` is
/// not. With no `ls` under `sessions` the sessions reading fails to parse,
/// the as-written reading `omh ls` parses as the **top-level inventory**, and
/// `session_prefix`'s fallback hands that reading the launch because it is
/// not a `Cmd::Run`. `Cmd::Ls` never reads `cli.session`, so the session is
/// dropped in silence and every session is listed — which is verbatim the
/// harm the refusal removed in #67 existed to prevent: *"it would list every
/// session and look like it had listed one."*
///
/// So the verb survives as a tombstone rather than as a hole. Deleting it
/// from the parser did not make the line unspellable, only unrefusable.
#[test]
fn the_retired_listing_verb_is_refused_by_name_rather_than_widening() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    sb.session("s01");
    sb.session("s02");

    // The scoped spelling must not quietly become the wide one.
    let scoped = sb.omh(&["s01", "ls"]);
    let out = String::from_utf8_lossy(&scoped.stdout).to_string();
    let err = String::from_utf8_lossy(&scoped.stderr).to_string();
    assert!(
        !scoped.status.success(),
        "a retired verb is refused, not answered: {out}"
    );
    assert!(
        !out.contains("s02"),
        "and refusing means it never listed every session on the way: {out}"
    );
    assert!(
        err.contains("is the listing"),
        "the refusal names what replaced the verb: {err}"
    );

    // …and neither does the spelling people actually have in their fingers.
    let bare = sb.omh(&["s", "ls"]); // types the retired verb on purpose
    let err = String::from_utf8_lossy(&bare.stderr).to_string();
    assert!(!bare.status.success(), "the retired verb is not a command");
    assert!(
        err.contains("is the listing"),
        "a verb retired in favour of its own noun is one word away from what \
         the user meant, so the error says the word rather than leaving them \
         to read a usage line: {err}"
    );
}

/// A session omh said exists and then cannot find is an error, not `no
/// sessions`.
///
/// The focused listing checks the id up front, through the same
/// `existing_session` every other verb uses, and then filters the rows it
/// built independently. The two disagree about what a session *is*:
/// `existing_session` asks whether the path exists, `session::list` asks
/// whether it is a directory. Anything that is one and not the other — a
/// stray file, and a worktree removed between the check and the read, which
/// is a wide window full of subprocesses — passes the first and vanishes at
/// the second.
///
/// What the user then sees is `no sessions` on stdout with exit 0, which is
/// the same byte-for-byte answer a clean checkout gives. A question omh could
/// not answer must not render like an answer, least of all like the answer
/// *nothing is here*.
#[test]
fn a_session_that_vanishes_between_the_check_and_the_read_is_not_no_sessions() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    let worktree = sb.session("s01");

    // A plain file where a worktree would be: `exists()` says yes, `is_dir()`
    // says no. The race has the same shape and is not reproducible on demand.
    let stray = worktree.parent().unwrap().join("s02");
    std::fs::write(&stray, "").unwrap();

    let out = sb.omh(&["s02"]);
    let printed = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(
        !printed.contains("no sessions"),
        "omh looked, disagreed with itself, and reported an empty world: {printed}"
    );
    assert!(
        !out.status.success(),
        "and it exits non-zero, so a script cannot read the disagreement as \
         an answer: {printed}"
    );

    // The unfocused listing is unaffected — s01 is still there to report.
    let all = String::from_utf8_lossy(&sb.omh(&["s"]).stdout).to_string();
    assert!(all.contains("s01"), "`omh s` still answers: {all}");
}

/// `--session` is the same selector as the `sNN` prefix, including the
/// checking.
///
/// The prefix can only ever produce `s\d+`, so every assertion written
/// against it leaves `validate_id` — a path-traversal guard — unreached.
/// `--session` is the spelling that carries an arbitrary string into a path
/// join, and it had no test at all.
#[test]
fn the_long_spelling_of_the_selector_scopes_and_checks_the_same() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    sb.session("s01");
    sb.session("s02");

    let long = String::from_utf8_lossy(&sb.omh(&["s", "--session", "s01"]).stdout).to_string();
    let prefix = String::from_utf8_lossy(&sb.omh(&["s01"]).stdout).to_string();
    assert_eq!(
        long, prefix,
        "`omh s --session s01` and `omh s01` are one command spelled two ways"
    );

    // A name that is not a session id is refused rather than joined into a
    // path and listed as nothing.
    let traversal = sb.omh(&["s", "--session", "../../etc"]);
    let err = String::from_utf8_lossy(&traversal.stderr).to_string();
    assert!(
        !traversal.status.success(),
        "a selector that is not an id is refused: {}",
        String::from_utf8_lossy(&traversal.stdout)
    );
    // Refused for being a path, not for naming nothing. Both refuse here, so
    // only the message distinguishes them — and dropping `validate_id` would
    // leave the traversal to be judged by whether the joined path happens to
    // exist, which is a different question with the same answer today.
    assert!(
        err.contains("not a path"),
        "the refusal is about the shape of the name, not about what it \
         happens to point at: {err}"
    );
}

/// A launch whose probe cannot be read does not destroy the running sandbox.
///
/// The end-to-end half of the guard, and the one that is red on the commit
/// before it: with the probe collapsed to *this container cannot reach its
/// worktree*, omh reaches `docker rm -f` on a container it was told is
/// running, and the agent inside loses its turn.
///
/// Asserted on the call log rather than on the message, because `rm -f` is the
/// thing that costs somebody their work — a refusal that still removed the
/// container would read correctly and be the whole bug.
#[test]
fn a_launch_that_cannot_read_the_probe_removes_nothing() {
    let sb = sandbox();
    let log = sb.fake_docker();
    sb.seed_catalogue(&["adapters", "base", "editors", "stacks"]);
    sb.session("s01");
    // Running, so the launch takes the reuse path rather than building.
    std::fs::write(sb.bin.join("containers"), "omh-repo-s01\n").unwrap();
    // …and then will not let omh in, for a reason that is neither of the two
    // omh may act on: the daemon died between the two calls.
    //
    // The wording is docker 29.7.2's own, measured. An invented one — "Error
    // response from daemon: dial unix … connection refused" — carries the
    // prefix that means *the daemon answered*, so it read as `Probe::Gone` and
    // the container was replaced. Which is the right behaviour for that
    // sentence, and the wrong test.
    std::fs::write(
        sb.bin.join("docker-exec-refuses"),
        "failed to connect to the docker API at unix:///var/run/docker.sock; check if \
         the path is correct and if the daemon is running\n",
    )
    .unwrap();

    let out = sb.omh(&["s01", "claude"]);
    assert!(
        !out.status.success(),
        "the launch stops rather than guessing"
    );

    let said = String::from_utf8_lossy(&out.stderr);
    assert!(
        said.contains("could not tell whether s01's sandbox is still usable"),
        "and says so: {said}"
    );
    assert!(
        said.contains("omh s01 down"),
        "with a way on that does not need the container entered: {said}"
    );

    let asked = std::fs::read_to_string(&log).unwrap_or_default();
    assert!(
        !asked.lines().any(|line| line.starts_with("rm -f")),
        "and nothing was removed — this is the half that costs work: {asked}"
    );
}

/// A runtime omh cannot reach is never reported as a sandbox that is stopped.
///
/// End to end, because the unit tests decide what each layer *says* and this
/// decides that the layers are wired to each other. The failure it guards is
/// specific and was live: with the Docker daemon down, `omh s` printed
/// `stopped` beside every session — in both formats, with nothing on stderr —
/// and `omh sNN sync` read the same false all-clear and would have written
/// over the files of a live agent.
#[test]
fn a_runtime_that_cannot_be_reached_is_not_reported_as_a_stopped_sandbox() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    sb.session("s01");
    std::fs::write(sb.bin.join("docker-refuses"), "").unwrap();

    let out = sb.omh(&["s"]);
    let printed = String::from_utf8_lossy(&out.stdout);
    // Anchored on the row being there at all. `Sessions::human` renders an
    // empty list as `no sessions`, which contains neither `stopped` nor `s01`
    // — so the absence assertion below passed on a listing with nothing in it.
    assert!(printed.contains("s01"), "the session is listed: {printed}");
    assert!(
        !printed.contains("stopped"),
        "a question omh could not answer is not an answer: {printed}"
    );
    assert!(
        printed.contains("up?"),
        "it is rendered as the question it is: {printed}"
    );
    // The reason, which the first version of this built, carried through two
    // layers and dropped — while the docs promised it was here.
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.contains("could not tell whether s01's sandbox is running"),
        "and the reason reaches stderr: {err}"
    );

    // The JSON has no second signal — `--json` returns before asides — so the
    // field is the whole of what a script gets.
    let json = sb.omh(&["s", "--json"]);
    let doc: serde_json::Value =
        serde_json::from_slice(&json.stdout).expect("`omh s --json` is a document");
    // `serde_json` indexing returns `Null` for a missing key, a non-array and
    // an out-of-range index alike, so `doc["sessions"][0]["running"]` was
    // `Null` for an empty document too. The length and the id anchor it.
    assert_eq!(
        doc["sessions"].as_array().map(Vec::len),
        Some(1),
        "one session in the document: {doc}"
    );
    assert_eq!(
        doc["sessions"][0]["id"],
        serde_json::json!("s01"),
        "and it is s01: {doc}"
    );
    assert_eq!(
        doc["sessions"][0]["running"],
        serde_json::Value::Null,
        "and a script is not told `false`: {doc}"
    );
    assert!(
        doc["sessions"][0]["running_unknown"].is_string(),
        "with the reason beside it, since `--json` never sees the warning: {doc}"
    );

    // The one that matters. A sync here would land files under an agent that
    // may well be mid-turn.
    let sync = sb.omh(&["s01", "sync"]);
    assert!(!sync.status.success(), "sync does not proceed on a guess");
    let err = String::from_utf8_lossy(&sync.stderr);
    assert!(
        err.contains("could not tell whether s01 is running"),
        "and says why it stopped: {err}"
    );
}

/// `down` over an unreachable runtime reports the session it could not ask
/// about, rather than leaving a hole where the row should be.
///
/// The hole was the point: skipping the push meant `omh down` printed
/// **`no sessions`** on stdout — the answer channel — and `"sessions": []` in
/// JSON, over a daemon it never reached. A missing row is worse than a wrong
/// one; a script iterating the list sees nothing at all and no error.
#[test]
fn down_over_an_unreachable_runtime_still_names_the_session() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    sb.session("s01");
    std::fs::write(sb.bin.join("docker-refuses"), "").unwrap();

    let out = sb.omh(&["s01", "down", "--json"]);
    assert!(!out.status.success(), "it is a failure, and exits like one");

    let doc: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("down --json is a document");
    assert_eq!(
        doc["sessions"].as_array().map(Vec::len),
        Some(1),
        "the session is in the document: {doc}"
    );
    assert_eq!(doc["sessions"][0]["session"], serde_json::json!("s01"));
    assert_eq!(
        doc["sessions"][0]["stopped"],
        serde_json::Value::Null,
        "`null`, not `false` — omh never asked it to stop: {doc}"
    );
    assert!(
        doc["sessions"][0]["why"].is_string(),
        "with the runtime's reason beside it: {doc}"
    );

    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.contains("could not be asked") && !err.contains("would not stop"),
        "and it does not claim the container refused to stop: {err}"
    );
}

#[test]
fn the_listing_names_what_removed_sessions_left_behind() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    std::fs::write(sb.bin.join("containers"), "omh-repo-s03\n").unwrap();

    let launched = sb.home.join(".omh/run/repo/s02");
    std::fs::create_dir_all(&launched).unwrap();
    std::fs::write(launched.join("last-used"), "").unwrap();
    std::fs::create_dir_all(sb.home.join(".omh/run/repo/doctor")).unwrap();

    let out = sb.omh(&["s"]);
    // On **stderr**: a leftover is something wrong, not what `omh s` was asked
    // for, and `omh s > sessions.txt` must not collect it.
    let printed = String::from_utf8_lossy(&out.stderr);
    assert!(
        printed.contains("s02"),
        "a run directory left behind: {printed}"
    );
    assert!(
        printed.contains("s03"),
        "a container left behind: {printed}"
    );
    assert!(
        !printed.contains("doctor"),
        "scratch staging is not a removed session: {printed}"
    );
}

/// A focused listing does not report other sessions' leftovers.
///
/// A leftover is an id with a container or a run directory and **no
/// worktree**, so the focused id can never be one: `existing_session` proved
/// it has a worktree before the sweep runs. The overlap section earns its
/// place in a focused view because a collision is a fact about two sessions;
/// a leftover is a fact about neither, and it is guaranteed — not merely
/// likely — to be about somebody else. `omh s` is where orphans belong.
#[test]
fn a_focused_listing_leaves_other_sessions_leftovers_to_the_wide_one() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    sb.session("s01");
    std::fs::write(sb.bin.join("containers"), "omh-repo-s03\n").unwrap();
    let launched = sb.home.join(".omh/run/repo/s02");
    std::fs::create_dir_all(&launched).unwrap();
    std::fs::write(launched.join("last-used"), "").unwrap();

    let focused = sb.omh(&["s01"]);
    let aside = String::from_utf8_lossy(&focused.stderr).to_string();
    assert!(
        !aside.contains("s02") && !aside.contains("s03"),
        "asked about s01, told about s02 and s03: {aside}"
    );

    // …and the wide listing still reports them, so this narrowed the view
    // rather than dropping the fact.
    let wide = String::from_utf8_lossy(&sb.omh(&["s"]).stderr).to_string();
    assert!(
        wide.contains("s02") && wide.contains("s03"),
        "`omh s` is still where leftovers are named: {wide}"
    );
}

/// The promise in `docs/commands.md`, pinned against the command that broke it.
///
/// *stdout is the answer; stderr is everything else* was documented and then
/// contradicted by this exact invocation: the leftovers warning and its
/// `omh s rm` hint were appended to the table, so both landed in the file. A
/// prose rule nothing checks is a rule that drifts back — this is the check.
#[test]
fn a_redirected_listing_collects_the_sessions_and_nothing_else() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    std::fs::write(sb.bin.join("containers"), "omh-repo-s03\n").unwrap();

    let out = sb.omh(&["s"]);
    let answer = String::from_utf8_lossy(&out.stdout);
    let aside = String::from_utf8_lossy(&out.stderr);

    assert!(
        !answer.contains("rm"),
        "a next step is not part of a redirected answer — got {answer:?}"
    );
    assert!(
        !answer.contains("left something behind"),
        "nor is a warning — got {answer:?}"
    );
    assert!(
        aside.contains("left something behind") && aside.contains("rm"),
        "both still reach the person watching — got {aside:?}"
    );
}

/// `--json` carries the same facts as fields, and drops the prose entirely.
///
/// The asides are suppressed rather than merely unstyled: `leftovers` is
/// already in the document, so a sentence about it on stderr is a second copy
/// for something that is parsing the first.
#[test]
fn json_carries_leftovers_as_a_field_and_says_nothing_about_them() {
    let sb = sandbox();
    let _log = sb.fake_docker();
    std::fs::write(sb.bin.join("containers"), "omh-repo-s03\n").unwrap();

    let out = sb.omh(&["s", "--json"]);
    let doc: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("`--json` is one document");

    assert_eq!(
        doc["leftovers"],
        serde_json::json!(["s03"]),
        "the fact is a field"
    );
    assert_eq!(
        String::from_utf8_lossy(&out.stderr),
        "",
        "and the prose about it is gone"
    );
}

// ── omh import hooks ────────────────────────────────────────────────────────
//
// The one part of this feature whose end-to-end path runs here: importing
// needs no container and no git, so these drive the real binary against a real
// file rather than asserting on a function's return value.

impl Sandbox {
    /// The shipped adapters, where `Paths::adapters()` looks. `init` would put
    /// them there and needs a container to finish.
    fn seed_adapters(&self) {
        self.seed_catalogue(&["adapters"]);
    }

    /// The shipped catalogue, or the parts of it a test needs.
    ///
    /// `init` stages all of this and needs a container to finish, so a test
    /// that drives a launch has to stand it up itself. Copied from the repo
    /// rather than written inline: a fixture that invents an adapter is a
    /// fixture that stops resembling the thing users get.
    fn seed_catalogue(&self, kinds: &[&str]) {
        for kind in kinds {
            let src = Path::new(env!("CARGO_MANIFEST_DIR")).join(kind);
            let dst = self.home.join(".omh").join(kind);
            std::fs::create_dir_all(&dst).unwrap();
            for entry in std::fs::read_dir(src).unwrap().flatten() {
                std::fs::copy(entry.path(), dst.join(entry.file_name())).unwrap();
            }
        }
    }

    /// A harness config to import from, in Claude Code's own shape.
    fn harness_hooks(&self, body: &str) -> PathBuf {
        let p = self.home.join("their-settings.json");
        std::fs::write(&p, body).unwrap();
        p
    }

    fn repo_hook(&self, name: &str) -> Option<String> {
        std::fs::read_to_string(self.repo.join(".omh/hooks").join(format!("{name}.json"))).ok()
    }
}

/// What somebody already configured arrives as omh hooks, in **this repo**.
///
/// The destination is the whole point of the first assertion: a catalogue hook
/// runs in every repo you ever open, so importing one project's formatter there
/// would put it in front of every other project you touch.
#[test]
fn importing_hooks_writes_them_into_this_repo() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    let theirs = fx.harness_hooks(
        r#"{"hooks":{
            "Stop":[{"matcher":"","hooks":[{"type":"command","command":"cargo test"}]}],
            "PostToolUse":[{"matcher":"Edit|Write|MultiEdit","hooks":[{"type":"command","command":"cargo fmt"}]}]}}"#,
    );

    let out = fx.omh(&[
        "import",
        "hooks",
        "claude",
        "--from",
        theirs.to_str().unwrap(),
    ]);
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );

    let test = fx
        .repo_hook("turn-end-cargo")
        .expect("the turn-end hook must be in this repo");
    assert!(test.contains("cargo test"), "got: {test}");
    assert!(
        test.contains("\"on\": \"turn-end\""),
        "in omh's words, not Claude's: {test}"
    );

    let fmt = fx
        .repo_hook("after-tool-cargo")
        .expect("the after-tool hook must be in this repo");
    assert!(
        fmt.contains("cargo fmt") && fmt.contains("edit"),
        "got: {fmt}"
    );

    // And nowhere else. The catalogue is yours, across every project.
    assert!(
        !fx.home.join(".omh/hooks/turn-end-cargo.json").exists(),
        "a repo's hook must not be installed into the catalogue"
    );
}

/// **Copy, never move.** Adopting omh is not a migration somebody cannot back
/// out of: the harness they were using keeps working exactly as it did.
///
/// Asserted on the source's **bytes**, not its existence — a file truncated,
/// rewritten or reformatted in place is still there.
#[test]
fn importing_leaves_the_harness_config_untouched() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    let body = r#"{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cargo test"}]}]}}"#;
    let theirs = fx.harness_hooks(body);

    fx.omh(&[
        "import",
        "hooks",
        "claude",
        "--from",
        theirs.to_str().unwrap(),
    ]);

    assert_eq!(
        std::fs::read_to_string(&theirs).unwrap(),
        body,
        "the harness's own config must be byte-for-byte what it was"
    );
}

/// **An imported hook that is not selected is a hook no session ships.**
///
/// `[use]` is what the launcher reads. A file written without being named there
/// is one `omh import` counted and reported and no launch will ever run — the
/// report says `+2` and the session ships none of them, which is the most
/// likely silent failure this feature has.
#[test]
fn imported_hooks_are_selected_or_they_land_dead() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    // A repo that has curated its selection: `init` writes one, and after that
    // a hook not in it is off.
    std::fs::create_dir_all(fx.repo.join(".omh")).unwrap();
    std::fs::write(fx.repo.join(".omh/settings.toml"), "[use]\nhooks = []\n").unwrap();
    let theirs = fx.harness_hooks(
        r#"{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cargo test"}]}]}}"#,
    );

    let out = fx.omh(&[
        "import",
        "hooks",
        "claude",
        "--from",
        theirs.to_str().unwrap(),
    ]);
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );

    assert!(
        fx.settings().contains("turn-end-cargo"),
        "an imported hook must reach `[use]`, or it never runs: {}",
        fx.settings()
    );
}

/// **A hook answering to a name omh ships is refused**, and the reason is
/// worse than shadowing: `render::merge_hooks` treats it as an error naming
/// both files, so the whole session fails rather than that one hook.
///
/// Asserted through the real consumer — `omh why`, which builds the same
/// profile a launch does — rather than by checking the file is absent. What
/// matters is that omh still works afterwards.
#[test]
fn importing_refuses_a_name_omh_ships() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    let theirs = fx.harness_hooks(
        r#"{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"graph-refresh --now"}]}]}}"#,
    );

    let out = fx.omh(&[
        "import",
        "hooks",
        "claude",
        "--from",
        theirs.to_str().unwrap(),
    ]);
    let said = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(out.status.success(), "importing is not fatal: {said}");

    // `graph-refresh` is omh's. Whatever omh did with it, a launch must still
    // be able to compose this repo.
    let why = fx.omh(&["why", "codegraph"]);
    assert!(
        why.status.success(),
        "importing left this repo unable to launch: {}",
        String::from_utf8_lossy(&why.stderr)
    );
}

/// A handler omh cannot express whole is **left where it is**, and named. It is
/// still in the harness's own file and still running there, which is honest —
/// but somebody who was not told would think omh had taken everything.
#[test]
fn what_omh_cannot_import_is_reported_rather_than_dropped() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    let theirs = fx.harness_hooks(
        r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[
            {"type":"command","command":"guard","if":"tool.name == 'Bash'"}]}]}}"#,
    );

    let out = fx.omh(&[
        "import",
        "hooks",
        "claude",
        "--from",
        theirs.to_str().unwrap(),
    ]);
    let said = String::from_utf8_lossy(&out.stdout);
    assert!(out.status.success(), "{said}");
    assert!(said.contains("left"), "the residue is reported: {said}");
    assert!(
        fx.repo_hook("before-tool-guard").is_none(),
        "and a hook whose permission gate omh cannot express is not written \
         without it"
    );
}

// ── omh import <capability> ─────────────────────────────────────────────────

impl Sandbox {
    /// A harness's own catalogue, on the host, where `import` reads.
    fn theirs(&self, at: &str, body: &str) -> PathBuf {
        let p = self.home.join(at);
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
        std::fs::write(&p, body).unwrap();
        p
    }

    fn mine(&self, at: &str) -> Option<String> {
        std::fs::read_to_string(self.home.join(".omh").join(at)).ok()
    }
}

/// **Skills, commands and subagents go to the catalogue, not the repo.**
///
/// The opposite of hooks, and the reason is the one the docs give: a skill is a
/// way *you* work and travels with you across projects, while a hook binds to
/// one repo's commands. A skill imported into a repo would be a skill you only
/// had in one place.
#[test]
fn importing_a_skill_puts_it_in_your_catalogue() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    fx.theirs(
        ".claude/skills/review-diff/SKILL.md",
        "---\nname: review-diff\ndescription: read a diff\n---\n\nbody\n",
    );
    fx.theirs(".claude/skills/review-diff/notes/extra.md", "more\n");

    let out = fx.omh(&["import", "skills", "claude"]);
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );

    assert!(
        fx.mine("skills/review-diff/SKILL.md")
            .is_some_and(|s| s.contains("read a diff")),
        "the skill must be in your catalogue"
    );
    assert!(
        fx.mine("skills/review-diff/notes/extra.md").is_some(),
        "a skill is a directory and arrives whole, not just its SKILL.md"
    );
    assert!(
        !fx.repo.join(".omh/skills").exists(),
        "a skill is yours across every project, not this repo's"
    );
}

/// **Rules are imported from your own file, never this project's.**
///
/// `rules::compose` already puts the repo's `CLAUDE.md` into every session, so
/// importing that one would hand the agent the same prose twice — and would go
/// on doing it in every other repo, because the catalogue travels.
#[test]
fn importing_rules_takes_yours_and_not_this_projects() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    fx.theirs(".claude/CLAUDE.md", "always write the test first\n");
    std::fs::write(fx.repo.join("CLAUDE.md"), "this project uses tabs\n").unwrap();

    let out = fx.omh(&["import", "rules", "claude"]);
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );

    let imported = fx.mine("rules/claude.md").expect("your own rules");
    assert!(
        imported.contains("always write the test first"),
        "got: {imported}"
    );
    assert!(
        !imported.contains("tabs"),
        "the project's own rules are composed already — importing them delivers \
         the same prose twice: {imported}"
    );
}

/// **A symlink is refused**, rather than followed or copied as a link.
///
/// The catalogue is mounted into every sandbox omh launches, so a link reaching
/// outside a skill would become a file the agent can read — in every project,
/// from a copy nobody had reason to inspect. Following it is an exfiltration
/// path; copying the link verbatim points somewhere else once the entry moves.
#[cfg(unix)]
#[test]
fn importing_refuses_a_skill_that_reaches_outside_itself() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    let secret = fx.theirs("secrets/id_rsa", "PRIVATE KEY\n");
    fx.theirs(".claude/skills/sneaky/SKILL.md", "---\nname: sneaky\n---\n");
    std::os::unix::fs::symlink(&secret, fx.home.join(".claude/skills/sneaky/borrowed.pem"))
        .unwrap();

    let out = fx.omh(&["import", "skills", "claude"]);
    let said = String::from_utf8_lossy(&out.stdout);
    assert!(out.status.success(), "one bad entry is not fatal: {said}");
    assert!(said.contains("skipped"), "and it is reported: {said}");
    assert!(
        fx.mine("skills/sneaky/borrowed.pem").is_none()
            && fx.mine("skills/sneaky/SKILL.md").is_none(),
        "an entry omh cannot copy whole is not copied in part"
    );
}

/// A name that is not a name never becomes a catalogue entry. `..` and a
/// separator are refused by the same rule `[use]` applies, so a path cannot be
/// smuggled in where an entry belongs.
#[cfg(unix)]
#[test]
fn importing_refuses_an_entry_whose_name_is_a_path() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    fx.theirs(".claude/commands/.hidden.md", "not an entry\n");
    fx.theirs(".claude/commands/real.md", "an entry\n");

    let out = fx.omh(&["import", "commands", "claude"]);
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );

    assert!(
        fx.mine("commands/real.md").is_some(),
        "the good one arrives"
    );
    assert!(
        fx.mine("commands/.hidden.md").is_none(),
        "a dotfile is not a catalogue entry"
    );
}

/// Import never clobbers. An entry you have since edited is left exactly as it
/// is, and re-running is a no-op — the rule `omh config mcp import` already
/// follows.
#[test]
fn importing_twice_changes_nothing_the_second_time() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    fx.theirs(".claude/commands/review.md", "theirs\n");

    fx.omh(&["import", "commands", "claude"]);
    std::fs::write(fx.home.join(".omh/commands/review.md"), "mine, edited\n").unwrap();
    let out = fx.omh(&["import", "commands", "claude"]);

    assert!(out.status.success());
    assert_eq!(
        fx.mine("commands/review.md").as_deref(),
        Some("mine, edited\n"),
        "an import must not replace what you have since written"
    );
}

/// **A copy that fails part-way leaves nothing behind.**
///
/// The symlink check runs before anything is written, so it never reaches this
/// path — which is exactly why the cleanup needed its own test: deleting it
/// changed nothing, and the failure it guards against is the one nobody
/// arranges. A skill half-copied into the catalogue is mounted into every
/// sandbox exactly as a whole one is, and reads as an entry somebody chose.
///
/// Triggered with an unreadable file, so the failure is real rather than
/// injected. Skipped as root, where the permission would not bite and the test
/// would pass for the wrong reason.
#[cfg(unix)]
#[test]
fn a_copy_that_fails_part_way_leaves_nothing_behind() {
    use std::os::unix::fs::PermissionsExt;
    if unsafe { libc::geteuid() } == 0 {
        eprintln!("skipped: root reads an unreadable file, so this proves nothing");
        return;
    }
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    fx.theirs(".claude/skills/big/SKILL.md", "---\nname: big\n---\n");
    let locked = fx.theirs(".claude/skills/big/zz-locked.md", "secret\n");
    std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();

    let out = fx.omh(&["import", "skills", "claude"]);
    let said = String::from_utf8_lossy(&out.stdout);
    assert!(out.status.success(), "one bad entry is not fatal: {said}");
    assert!(said.contains("skipped"), "and is reported: {said}");
    assert!(
        !fx.home.join(".omh/skills/big").exists(),
        "a half-copied entry must not survive: it is mounted into every sandbox \
         exactly as a whole one is"
    );
}

/// **A hook `init` derives on a re-run reaches `[use]`, or it lands dead.**
///
/// `merge_hooks` drops any hook the selection does not name, and a repo that
/// has been `init`ed once has a curated `[use]` that `init` will not resync. So
/// a project that gains a `package.json` six months later gets `pnpm-test.json`
/// written, sees it reported, and never runs it — the exact failure
/// `imported_hooks_are_selected_or_they_land_dead` pins for `omh import`.
///
/// Runs without a container: everything up to the harness block executes, and
/// the derived hook and the selection are both written before it.
#[test]
fn a_hook_init_derives_later_is_selected_too() {
    let fx = sandbox();
    fx.seed_base();
    fx.seed_adapters();
    // A repo already set up, with a list somebody has since curated.
    std::fs::create_dir_all(fx.repo.join(".omh")).unwrap();
    std::fs::write(fx.repo.join(".omh/settings.toml"), "[use]\nhooks = []\n").unwrap();
    // …which has since become a node project.
    std::fs::write(
        fx.repo.join("package.json"),
        r#"{"scripts":{"test":"vitest run"}}"#,
    )
    .unwrap();
    std::fs::write(fx.repo.join("pnpm-lock.yaml"), "").unwrap();

    let out = fx.omh(&["init"]);
    let said = String::from_utf8_lossy(&out.stdout);

    assert!(
        fx.repo.join(".omh/hooks/pnpm-test.json").exists(),
        "the hook is derived: {said}"
    );
    assert!(
        fx.settings().contains("pnpm-test"),
        "and named in `[use]`, or no session will ever run it: {}",
        fx.settings()
    );
}

/// **`--json` emits exactly one document, from every command that emits any.**
///
/// The bug this exists to stop is invisible to a unit test by construction.
/// Every `Report::json` in `src/report.rs` is exercised by calling `.json()` on
/// a hand-built value, which can only ever produce one object — but a *command*
/// chooses how many times to call `Ctx::say`, and four of them called it inside
/// a loop over the repo layers. Two layers, two objects, concatenated: valid
/// JSON twice over and a parse error once, in the format whose entire purpose
/// is to be parsed.
///
/// This is the file's stated reason for existing, in the module docs above — a
/// guard that is correct while the wiring reaching it is wrong.
///
/// Counted by closing braces in column zero, which is exact for
/// `serde_json::to_string_pretty`: a top-level object closes there and nothing
/// nested does. Cruder than a parser and it needs no dependency the test target
/// does not already have.
#[test]
fn every_json_answer_is_one_document_and_not_several() {
    let sb = sandbox();
    sb.seed_base();
    sb.catalogue(&["skills/alpha/SKILL.md", "skills/beta/SKILL.md"]);
    std::fs::create_dir_all(sb.repo.join(".omh")).unwrap();

    // Both repo layers declare the same capability, which is what makes the
    // writers loop. A repo with one layer passes whatever the command does.
    std::fs::write(
        sb.repo.join(".omh/settings.toml"),
        "[use]\nskills = [\"alpha\"]\n\n[omh]\ncodegraph = true\n",
    )
    .unwrap();
    std::fs::write(
        sb.repo.join(".omh/settings.local.toml"),
        "[use]\nskills = [\"alpha\"]\n\n[omh]\ncodegraph = true\n",
    )
    .unwrap();

    for args in [
        vec!["--json", "ls"],
        vec!["--json", "repo"],
        vec!["--json", "config"],
        vec!["--json", "use", "skills", "beta"],
        vec!["--json", "unuse", "skills", "beta"],
        vec!["--json", "repo", "disable", "codegraph"],
        vec!["--json", "s"],
        vec!["--json", "memory", "ls"],
    ] {
        let out = sb.omh(&args);
        let stdout = String::from_utf8_lossy(&out.stdout).to_string();
        if stdout.trim().is_empty() {
            continue; // a command with nothing to say is not this test's business
        }
        let documents = stdout.lines().filter(|l| *l == "}").count();
        assert_eq!(
            documents,
            1,
            "`omh {}` emitted {documents} JSON documents, and a parser reads one:\n{stdout}",
            args.join(" ")
        );
    }
}

/// The reap has to be wired to the build, or the whole feature is a set of
/// well-tested functions nobody calls.
///
/// `superseded` is pure and thoroughly tested, and every one of those tests
/// stays green with the `reap` call deleted from `build` — measured: the unit
/// suite, `tests/cli.rs` under `--include-ignored`, and the doc tests all pass
/// with the feature disconnected, leaving four dead-code warnings as the only
/// signal. That is the original bug in its original form: images stop being
/// collected, nothing says so, and it is invisible until a disk fills.
///
/// Driven through `omh init` with a `docker` that reports nothing built, so a
/// real build runs and the reap after it is reached. No container runtime is
/// involved: what is asserted is which removals omh *asks* for, which is omh's
/// half of the bargain. Whether docker honours them is docker's, and no test
/// here can settle it.
#[test]
fn a_build_asks_docker_to_remove_the_tags_it_replaced() {
    let sb = sandbox();
    sb.git_init();
    let log = sb.fake_docker_with_nothing_built(
        &["omh/base:stale", "omh/base:latest", "omh/base:held"],
        &["omh/base:held"],
    );

    // Asked, not assumed. This has failed on CI with an empty removal list,
    // which is what a build that never ran looks like from here — and the
    // discarded status meant the message said nothing about why. Every
    // neighbouring test in this file already checks it.
    //
    // Both streams and the code, because the first version of this printed
    // stderr alone and the next failure put nothing there but progress lines:
    // a refusal with no reason, which is the state this whole file exists to
    // stop omh from producing. It has since failed on the linux runner three
    // times across three branches — including before any of the work that was
    // in flight when it first appeared — and no run has yet said why.
    let init = sb.omh(&["init"]);
    assert!(
        init.status.success(),
        "init failed ({}), so no build ran and no reap followed it\n\
         --- stderr ---\n{}\n--- stdout ---\n{}\n--- docker was asked ---\n{}",
        init.status,
        String::from_utf8_lossy(&init.stderr),
        String::from_utf8_lossy(&init.stdout),
        sb.docker_calls(&log).join("\n")
    );

    let removals: Vec<String> = sb
        .docker_calls(&log)
        .into_iter()
        .filter(|c| c.starts_with("image rm "))
        .collect();
    assert!(
        removals.iter().any(|c| c.ends_with("omh/base:stale")),
        "the build never asked for the tag it replaced: {removals:?}"
    );
    assert!(
        !removals.iter().any(|c| c.ends_with("omh/base:latest")),
        "`latest` is the one removal that cannot be undone: {removals:?}"
    );
    assert!(
        !removals.iter().any(|c| c.ends_with("omh/base:held")),
        "a container still references it: {removals:?}"
    );
}