Loading...
   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
# SPDX-License-Identifier: GPL-2.0+
#
# Copyright 2025 Canonical Ltd.
# Written by Simon Glass <simon.glass@canonical.com>
#
# pylint: disable=too-many-lines
"""Control module for pickman - handles the main logic."""

from collections import namedtuple
from datetime import date
import os
import re
import sys
import tempfile
import time
import unittest

import requests

# Allow 'from pickman import xxx' to work via symlink
our_path = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, os.path.join(our_path, '..'))

# pylint: disable=wrong-import-position,import-error
from pickman import agent
from pickman import database
from pickman import ftest
from pickman import gitlab_api
from u_boot_pylib import command
from u_boot_pylib import terminal
from u_boot_pylib import tools
from u_boot_pylib import tout

# Default database filename
DB_FNAME = '.pickman.db'

# Branch names to compare
BRANCH_MASTER = 'ci/master'
BRANCH_SOURCE = 'us/next'

# Git stat output parsing patterns
RE_GIT_STAT_SUMMARY = re.compile(
    r'(\d+)\s+files?\s+changed'
    r'(?:,\s*(\d+)\s+insertions?\([+]\))?'
    r'(?:,\s*(\d+)\s+deletions?\([-]\))?'
)
RE_GIT_STAT_FILE = re.compile(r'^([^|]+)\s*\|')

# Extract hash from line like "(cherry picked from commit abc123def)"
RE_CHERRY_PICK = re.compile(r'cherry picked from commit ([a-f0-9]+)')

# Detect subtree merge commits on the first-parent chain
RE_SUBTREE_MERGE = re.compile(
    r"Subtree merge tag '([^']+)' of .* into (.*)")

# Map from subtree path to update-subtree.sh name
SUBTREE_NAMES = {
    'dts/upstream': 'dts',
    'lib/mbedtls/external/mbedtls': 'mbedtls',
    'lib/lwip/lwip': 'lwip',
}

# Return codes for _subtree_run_update()
SUBTREE_OK = 0
SUBTREE_FAIL = 1
SUBTREE_CONFLICT = 2

# Named tuple for commit info
Commit = namedtuple('Commit', ['hash', 'chash', 'subject', 'date'])

# Named tuple for git stat output
# files: Number of files changed
# inserted: Number of lines inserted
# deleted: Number of lines deleted
# file_set: Set of modified file paths
GitStat = namedtuple('GitStat', ['files', 'inserted', 'deleted', 'file_set'])

# Named tuple for check results
# chash: Cherry-pick commit hash (full)
# orig_hash: Original commit hash that was cherry-picked
# subject: Commit subject line
# delta_ratio: Ratio of differences between original and cherry-pick
#   (0.0=identical, 1.0=completely different)
# orig_stats: Stats from original commit (files, insertions, deletions,
#   file_set)
# cherry_stats: Stats from cherry-pick commit
# reason: Reason for skipping analysis (None if analyzed)
CheckResult = namedtuple('CheckResult', [
    'chash', 'orig_hash', 'subject', 'delta_ratio',
    'orig_stats', 'cherry_stats', 'reason'
])

# Named tuple for commit with author
# hash: Full SHA-1 commit hash (40 characters)
# chash: Abbreviated commit hash (typically 7-8 characters)
# subject: First line of commit message (commit subject)
# author: Commit author name and email in format "Name <email>"
CommitInfo = namedtuple('CommitInfo',
                        ['hash', 'chash', 'subject', 'author'])

# Named tuple for simplified commit data passed to agent
# hash: Full SHA-1 commit hash (40 characters)
# chash: Abbreviated commit hash (typically 7-8 characters)
# subject: First line of commit message (commit subject)
# applied_as: Short hash if potentially already applied, None otherwise
AgentCommit = namedtuple('AgentCommit',
                         ['hash', 'chash', 'subject', 'applied_as'])

# Named tuple for get_next_commits() result
#
# commits: list of CommitInfo to cherry-pick
# merge_found: True if these commits came from a merge on the source branch
# advance_to: hash to advance the source position to, or None to stay put
# subtree_update: (name, tag) tuple if a subtree update is needed, else None
NextCommitsInfo = namedtuple('NextCommitsInfo',
                             ['commits', 'merge_found', 'advance_to',
                              'subtree_update'],
                             defaults=[None])

# Named tuple for prepare_apply() result
#
# commits: list of AgentCommit to cherry-pick
# branch_name: name of the branch to create for the MR
# original_branch: branch name before any conflict suffix
# merge_found: True if these commits came from a merge on the source branch
# advance_to: hash to advance the source position to, or None to stay put
ApplyInfo = namedtuple('ApplyInfo',
                       ['commits', 'branch_name', 'original_branch',
                        'merge_found', 'advance_to'])


def parse_log_output(log_output, has_parents=False):
    """Parse git log output to extract CommitInfo tuples

    Args:
        log_output (str): Output from git log with format '%H|%h|%an|%s'
            or '%H|%h|%an|%s|%P' if has_parents is True
        has_parents (bool): If True, expects parents field at end and
            excludes it from subject parsing

    Returns:
        list: List of CommitInfo tuples
    """
    commits = []
    for line in log_output.split('\n'):
        if not line:
            continue
        parts = line.split('|')
        commit_hash = parts[0]
        chash = parts[1]
        author = parts[2]
        if has_parents:
            subject = '|'.join(parts[3:-1])
        else:
            subject = '|'.join(parts[3:])
        commits.append(CommitInfo(commit_hash, chash, subject, author))
    return commits


def run_git(args):
    """Run a git command and return output."""
    return command.output('git', *args).strip()


def compare_branches(master, source):
    """Compare two branches and return commit difference info.

    Args:
        master (str): Main branch to compare against
        source (str): Source branch to check for unique commits

    Returns:
        tuple: (count, Commit) where count is number of commits and Commit
            is the last common commit
    """
    # Find commits in source that are not in master
    count = int(run_git(['rev-list', '--count', f'{master}..{source}']))

    # Find the merge base (last common commit)
    base = run_git(['merge-base', master, source])

    # Get details about the merge-base commit
    info = run_git(['log', '-1', '--format=%H%n%h%n%s%n%ci', base])
    full_hash, chash, subject, commit_date = info.split('\n')

    return count, Commit(full_hash, chash, subject, commit_date)


def do_add_source(args, dbs):
    """Add a source branch to the database

    Finds the merge-base commit between master and source and stores it.

    Args:
        args (Namespace): Parsed arguments with 'source' attribute
        dbs (Database): Database instance

    Returns:
        int: 0 on success
    """
    source = args.source

    # Find the merge base commit
    base_hash = run_git(['merge-base', BRANCH_MASTER, source])

    # Get commit details for display
    info = run_git(['log', '-1', '--format=%h%n%s', base_hash])
    chash, subject = info.split('\n')

    # Store in database
    dbs.source_set(source, base_hash)
    dbs.commit()

    tout.info(f"Added source '{source}' with base commit:")
    tout.info(f'  Hash:    {chash}')
    tout.info(f'  Subject: {subject}')

    return 0


def do_list_sources(args, dbs):  # pylint: disable=unused-argument
    """List all tracked source branches

    Args:
        args (Namespace): Parsed arguments
        dbs (Database): Database instance

    Returns:
        int: 0 on success
    """
    sources = dbs.source_get_all()

    if not sources:
        tout.info('No source branches tracked')
    else:
        tout.info('Tracked source branches:')
        for name, last_commit in sources:
            tout.info(f'  {name}: {last_commit[:12]}')

    return 0


def do_compare(args, dbs):  # pylint: disable=unused-argument
    """Compare branches and print results.

    Args:
        args (Namespace): Parsed arguments
        dbs (Database): Database instance
    """
    count, base = compare_branches(BRANCH_MASTER, BRANCH_SOURCE)

    tout.info(f'Commits in {BRANCH_SOURCE} not in {BRANCH_MASTER}: {count}')
    tout.info('')
    tout.info('Last common commit:')
    tout.info(f'  Hash:    {base.chash}')
    tout.info(f'  Subject: {base.subject}')
    tout.info(f'  Date:    {base.date}')

    return 0


def parse_git_stat_output(stat_output):
    """Parse git show --stat output to extract file change statistics

    Args:
        stat_output (str): Output from 'git show --stat <hash>'

    Returns:
        GitStat: Named tuple with files, insertions, deletions, file_set
    """
    lines = stat_output.strip().split('\n')
    files_changed = 0
    insertions = 0
    deletions = 0
    changed_files = set()

    # Parse summary line: "5 files changed, 42 insertions(+), 13 deletions(-)"
    for line in lines:
        match = RE_GIT_STAT_SUMMARY.search(line)
        if match:
            files_changed = int(match.group(1))
            insertions = int(match.group(2)) if match.group(2) else 0
            deletions = int(match.group(3)) if match.group(3) else 0
            break

    # Parse individual file lines: "path/to/file.ext | 42 ++++----"
    for line in lines:
        match = RE_GIT_STAT_FILE.match(line)
        if match:
            filename = match.group(1).strip()
            if filename:
                changed_files.add(filename)

    return GitStat(files_changed, insertions, deletions, changed_files)


def calc_ratio(orig, cherry):
    """Get the ratio of differences between original and cherry-picked commits

    Args:
        orig (GitStat): Stats for original commit
        cherry (GitStat): Stats for cherry-pick commit

    Returns:
        float: Delta ratio (0.0 = identical, 1.0 = completely
            different)
    """
    # If both commits have no changes, they're identical
    if not (orig.inserted + orig.deleted) and not (cherry.inserted +
                                                    cherry.deleted):
        return 0.0

    # Calculate file set difference
    if orig.file_set or cherry.file_set:
        union = orig.file_set | cherry.file_set
        intersection = orig.file_set & cherry.file_set
        similarity = (len(intersection) / len(union) if union else 1.0)
    else:
        similarity = 1.0

    # Calculate line change difference
    orig_lines = orig.inserted + orig.deleted
    cherry_lines = cherry.inserted + cherry.deleted

    if not orig_lines and not cherry_lines:
        line_similarity = 1.0
    elif not orig_lines or not cherry_lines:
        line_similarity = 0.0
    else:
        line_ratio = (min(orig_lines, cherry_lines) /
                      max(orig_lines, cherry_lines))
        line_similarity = line_ratio

    # Overall similarity is the minimum of file and line similarity
    overall_similarity = min(similarity, line_similarity)

    # Delta ratio is 1 - similarity
    return 1.0 - overall_similarity


def get_orig_commit(cherry_commit_hash):
    """Find the original commit hash from a cherry-pick commit

    Args:
        cherry_commit_hash (str): Hash of the cherry-picked commit

    Returns:
        str: Original commit hash, or None if not found
    """
    try:
        # Get the commit message
        commit_msg = run_git(['log', '-1', '--format=%B', cherry_commit_hash])

        # Look for "(cherry picked from commit <hash>)" line
        for line in commit_msg.split('\n'):
            if 'cherry picked from commit' in line:
                match = RE_CHERRY_PICK.search(line)
                if match:
                    return match.group(1)

        return None
    except Exception:  # pylint: disable=broad-except
        return None


def check_commits(commits, min_lines):
    """Yield CheckResult entries for commits with delta analysis

    Args:
        commits (list): List of (commit_hash, chash, subject) tuples
        min_lines (int): Minimum lines changed to analyze

    Yields:
        CheckResult: Analysis result for each commit
    """
    for chash, _, subject in commits:
        # Skip merge commits
        is_merge = False
        try:
            parents = run_git(['log', '-1', '--format=%P', chash]).split()
            if len(parents) > 1:
                is_merge = True
        except Exception:  # pylint: disable=broad-except
            pass

        # Also check subject for merge indicators
        if not is_merge and (subject.startswith('Merge ') or
                             'Merge branch' in subject or
                             'Merge tag' in subject):
            is_merge = True

        if is_merge:
            yield CheckResult(
                chash, None, subject, 0.0,
                None, None, 'merge_commit'
            )
            continue

        # Find original commit
        orig_hash = get_orig_commit(chash)
        if not orig_hash:
            yield CheckResult(
                chash, None, subject, 0.0,
                None, None, 'not_cherry_pick'
            )
            continue

        # Get stats for both commits
        orig_stat = run_git(['show', '--stat', orig_hash])
        cherry_stat = run_git(['show', '--stat', chash])

        # Parse statistics
        orig_stats = parse_git_stat_output(orig_stat)
        cherry_stats = parse_git_stat_output(cherry_stat)

        # Skip small commits
        orig_total_lines = orig_stats.inserted + orig_stats.deleted
        cherry_total_lines = cherry_stats.inserted + cherry_stats.deleted
        max_lines = max(orig_total_lines, cherry_total_lines)

        if max_lines < min_lines:
            yield CheckResult(
                chash, orig_hash, subject, 0.0,
                orig_stats, cherry_stats, f'small_commit_{max_lines}_lines'
            )
            continue

        # Calculate delta ratio
        delta_ratio = calc_ratio(orig_stats, cherry_stats)

        yield CheckResult(
            chash, orig_hash, subject, delta_ratio,
            orig_stats, cherry_stats, None
        )


def check_verbose(result, threshold):
    """Print verbose output for a single check result

    Args:
        result (CheckResult): The check result to print
        threshold (float): Delta threshold for highlighting problems
    """
    chash_short = result.chash[:10]

    if result.reason:
        if result.reason == 'merge_commit':
            tout.info(f'{chash_short}: {result.subject}')
            tout.info('  → Skipped (merge commit)')
            tout.info('')
        elif result.reason == 'not_cherry_pick':
            tout.info(f'{chash_short}: {result.subject}')
            tout.info('  → Not a cherry-pick (no original commit found)')
            tout.info('')
        elif result.reason.startswith('small_commit'):
            lines = result.reason.split('_')[2]
            tout.info(f'{chash_short}: {result.subject}')
            tout.info(f'  → Skipped (only {lines} lines changed)')
            tout.info('')
        elif result.reason.startswith('error'):
            error = result.reason[6:]  # Remove 'error_' prefix
            tout.info(f'{chash_short}: {result.subject}')
            tout.info(f'  → Error checking delta: {error}')
            tout.info('')
    else:
        # Valid result with analysis
        tout.info(f'{chash_short}: {result.subject}')
        tout.info(f'  → Original: {result.orig_hash[:12]} '
                  f'({result.orig_stats.files} files, '
                  f'{result.orig_stats.inserted}+/'
                  f'{result.orig_stats.deleted}- lines)')
        tout.info(f'  → Cherry-pick: {result.cherry_stats.files} files, '
                  f'{result.cherry_stats.inserted}+/'
                  f'{result.cherry_stats.deleted}- lines')
        if result.delta_ratio > threshold:
            tout.info(f'  → Delta ratio: {result.delta_ratio:.1%} '
                      f'⚠️  LARGE DELTA!')
        else:
            tout.info(f'  → Delta ratio: {result.delta_ratio:.1%} ✓')
        tout.info('')


def print_check_header():
    """Print the standard header for check output table"""
    header = (f'{"Cherry-pick":<11} {"Delta%":>6} '
              f'{"Original":<10} Subject')
    dashes = f'{"-" * 11} {"-" * 6} {"-" * 10} -------'
    tout.info(header)
    tout.info(dashes)


def format_problem_commit(result, threshold):
    """Format a problematic commit in the standard table format

    Args:
        result (CheckResult): The check result to format
        threshold (float): Delta threshold for coloring

    Returns:
        str: Formatted commit line
    """
    delta_pct_val = result.delta_ratio * 100
    delta_pct = f'{delta_pct_val:.0f}'
    pct_field = f'{delta_pct:>6}'

    # Apply color
    col = terminal.Color()
    threshold_pct = threshold * 100
    if delta_pct_val >= 50:
        pct_field = col.build(terminal.Color.RED, pct_field)
    elif delta_pct_val >= threshold_pct:
        pct_field = col.build(terminal.Color.YELLOW, pct_field)

    return (f'{result.chash[:10]}  {pct_field} '
            f'{result.orig_hash[:10]} {result.subject}')


def get_branch_commits():
    """Get commits on current branch that differ from ci/master

    Returns:
        tuple: (current_branch, commits) where commits is a list of
            (full_hash, short_hash, subject) tuples
    """
    current_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD'])

    # Get all commits on current branch that aren't in ci/master
    commit_list = run_git(['log', '--reverse', '--format=%H|%h|%s',
                          f'{BRANCH_MASTER}..HEAD'])

    if not commit_list:
        return current_branch, []

    # Parse commit_list format: "full_hash|short_hash|subject" per line
    commits = []
    for line in commit_list.split('\n'):
        if line:
            parts = line.split('|', 2)
            commits.append((parts[0], parts[1], parts[2]))

    return current_branch, commits


def check_already_applied(commits, target_branch='ci/master'):
    """Check which commits are already applied to the target branch

    Args:
        commits (list): List of CommitInfo tuples to check
        target_branch (str): Branch to check against (default: ci/master)

    Returns:
        tuple: (new_commits, applied) where:
            new_commits: list of CommitInfo for commits not yet applied
            applied: list of CommitInfo for commits already applied
    """
    new_commits = []
    applied = []

    for commit in commits:
        # Check if a commit with the same subject exists in target branch
        try:
            # Use git log with --grep to search for the subject
            # Escape any special characters in the subject for grep
            escaped_subject = commit.subject.replace('"', '\\"')
            result = run_git(['log', '--oneline', target_branch,
                             f'--grep={escaped_subject}', '-1'])
            if result.strip():
                # Found a commit with the same subject
                applied.append(commit)
                tout.info(f'Skipping {commit.chash} (already applied): '
                         f'{commit.subject}')
            else:
                new_commits.append(commit)
        except Exception:  # pylint: disable=broad-except
            # If grep fails, assume the commit is not applied
            new_commits.append(commit)

    return new_commits, applied


def build_applied_map(commits):
    """Build a mapping of commit hashes to their applied counterparts

    Checks which commits have already been applied to the target branch
    and returns a dict mapping original hashes to the applied hashes.

    Args:
        commits (list): List of CommitInfo tuples to check

    Returns:
        dict: Mapping of original commit hash to applied commit hash
    """
    _, applied = check_already_applied(commits)

    applied_map = {}
    if applied:
        for c in applied:
            escaped_subject = c.subject.replace('"', '\\"')
            result = run_git(['log', '--oneline', 'ci/master',
                             f'--grep={escaped_subject}', '-1'])
            if result.strip():
                applied_hash = result.split()[0]
                applied_map[c.hash] = applied_hash
        tout.info(f'Found {len(applied)} potentially already applied'
                  ' commit(s)')
    return applied_map


def show_commit_diff(res, no_colour=False):
    """Show the difference between original and cherry-picked commit patches

    Args:
        res (CheckResult): Check result with commit hashes
        no_colour (bool): Disable colour output
    """
    tout.info(f'\n--- Patch diff between original {res.orig_hash[:8]} and '
              f'cherry-picked {res.chash[:8]} ---')

    # Get the patch content of each commit
    orig_patch = run_git(['show', '--no-ext-diff', res.orig_hash])
    cherry_patch = run_git(['show', '--no-ext-diff', res.chash])

    # Create temporary files and diff them
    with tempfile.NamedTemporaryFile(mode='w', suffix='_orig.patch',
                                     delete=False) as orig_file:
        orig_file.write(orig_patch)
        orig_path = orig_file.name

    with tempfile.NamedTemporaryFile(mode='w', suffix='_cherry.patch',
                                     delete=False) as cherry_file:
        cherry_file.write(cherry_patch)
        cherry_path = cherry_file.name

    try:
        # Diff the two patch files using system diff
        diff_args = ['diff', '-u']
        if not no_colour:
            diff_args.append('--color=always')
        diff_args.extend([orig_path, cherry_path])

        patch_diff = command.output(*diff_args, raise_on_error=False)
        if patch_diff:
            print(patch_diff)
        else:
            tout.info('(Patches are identical)')
    finally:
        # Clean up temporary files
        os.unlink(orig_path)
        os.unlink(cherry_path)

    tout.info('--- End patch diff ---\n')


def show_check_summary(bad, verbose, threshold, show_diff, no_colour):
    """Show summary of check results

    Args:
        bad (list): List of CheckResult objects with problems
        verbose (bool): Whether to show verbose output
        threshold (float): Delta threshold for problems
        show_diff (bool): Whether to show diffs for problems
        no_colour (bool): Whether to disable colour in diffs

    Returns:
        int: 0 if no problems, 1 if problems found
    """
    if bad:
        if verbose:
            tout.info(f'Found {len(bad)} commit(s) with large deltas:')
            tout.info('')
            print_check_header()
            for res in bad:
                tout.info(format_problem_commit(res, threshold))
                if show_diff:
                    show_commit_diff(res, no_colour)
        else:
            tout.info(f'{len(bad)} problem commit(s) found')
        return 1
    if verbose:
        tout.info('All cherry-picks have acceptable deltas ✓')
    return 0


def do_check(args, dbs):  # pylint: disable=unused-argument
    """Check current branch for cherry-picks with large deltas

    Args:
        args (Namespace): Parsed arguments with 'threshold', 'min_lines',
            'verbose', and 'diff' attributes
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 on failure
    """
    threshold = args.threshold
    min_lines = args.min_lines
    verbose = args.verbose
    show_diff = args.diff

    current_branch, commits = get_branch_commits()

    if verbose:
        tout.info(f'Checking branch: {current_branch}')
        tout.info(f'Delta threshold: {threshold:.1%}')
        tout.info(f'Minimum lines to check: {min_lines}')
        tout.info(f'Found {len(commits)} commits to check')
        tout.info('')

    bad = []
    header_printed = False

    # Process commits using the generator
    for res in check_commits(commits, min_lines):
        is_problem = not res.reason and res.delta_ratio > threshold

        if verbose:
            check_verbose(res, threshold)
        elif is_problem:
            # Non-verbose: only show problems on one line
            if not header_printed:
                print_check_header()
                header_printed = True
            tout.info(format_problem_commit(res, threshold))

        if is_problem:
            bad.append(res)
            if show_diff:
                show_commit_diff(res, args.no_colour)

    return show_check_summary(bad, verbose, threshold, show_diff,
                              args.no_colour)


def do_check_gitlab(args, dbs):  # pylint: disable=unused-argument
    """Check GitLab permissions for the configured token

    Args:
        args (Namespace): Parsed arguments with 'remote' attribute
        dbs (Database): Database instance (unused)

    Returns:
        int: 0 on success with sufficient permissions, 1 otherwise
    """
    remote = args.remote

    perms = gitlab_api.check_permissions(remote)
    if not perms:
        return 1

    tout.info(f"GitLab permission check for remote '{remote}':")
    tout.info(f"  Host:         {perms.host}")
    tout.info(f"  Project:      {perms.project}")
    tout.info(f"  User:         {perms.user}")
    tout.info(f"  Access level: {perms.access_name}")
    tout.info('')
    tout.info('Permissions:')
    tout.info(f"  Push branches:    {'Yes' if perms.can_push else 'No'}")
    tout.info(f"  Create MRs:       {'Yes' if perms.can_create_mr else 'No'}")
    tout.info(f"  Merge MRs:        {'Yes' if perms.can_merge else 'No'}")

    if not perms.can_create_mr:
        tout.warning('')
        tout.warning('Insufficient permissions to create merge requests!')
        tout.warning('The user needs at least Developer access level.')
        return 1

    tout.info('')
    tout.info('All required permissions are available.')
    return 0


def _check_subtree_merge(merge_hash):
    """Check if a merge commit is a subtree merge.

    Returns:
        tuple: (name, tag) where name is the subtree name (or None for
            unknown paths), or None if not a subtree merge
    """
    subject = run_git(['log', '-1', '--format=%s', merge_hash])
    match = RE_SUBTREE_MERGE.match(subject)
    if not match:
        return None
    tag = match.group(1)
    path = match.group(2)
    return SUBTREE_NAMES.get(path), tag


def find_unprocessed_commits(dbs, last_commit, source, merge_hashes):
    """Find the first merge with unprocessed commits

    Walks through the merge hashes in order, looking for one that has
    commits not yet tracked in the database. Decomposes mega-merges
    (merges containing sub-merges) into individual batches.

    Args:
        dbs (Database): Database instance
        last_commit (str): Hash of the last cherry-picked commit
        source (str): Source branch name
        merge_hashes (list): List of merge commit hashes to check

    Returns:
        NextCommitsInfo: Info about the next commits to process
    """
    prev_commit = last_commit
    skipped_merges = False
    for merge_hash in merge_hashes:
        # Check for subtree merge (e.g. dts/upstream update)
        result = _check_subtree_merge(merge_hash)
        if result is not None:
            name, tag = result
            if name:
                return NextCommitsInfo([], True, merge_hash,
                                       (name, tag))
            # Unknown subtree path - skip past it
            prev_commit = merge_hash
            skipped_merges = True
            continue

        # Check for mega-merge (contains sub-merges)
        sub_merges = detect_sub_merges(merge_hash)
        if sub_merges:
            commits, advance_to = decompose_mega_merge(
                dbs, prev_commit, merge_hash, sub_merges)
            if commits:
                return NextCommitsInfo(commits, True, advance_to)
            # All sub-merges done, skip past this mega-merge
            prev_commit = merge_hash
            skipped_merges = True
            continue

        # Get all commits from prev_commit to this merge
        log_output = run_git([
            'log', '--reverse', '--format=%H|%h|%an|%s|%P',
            f'{prev_commit}..{merge_hash}'
        ])

        if not log_output:
            prev_commit = merge_hash
            continue

        # Parse commits, filtering out those already in database
        all_commits = parse_log_output(log_output, has_parents=True)
        commits = [c for c in all_commits
                   if not dbs.commit_get(c.hash)]

        if commits:
            return NextCommitsInfo(commits, True, merge_hash)

        # All commits in this merge are processed, skip to next
        prev_commit = merge_hash
        skipped_merges = True

    # No merges with unprocessed commits, check remaining commits
    log_output = run_git([
        'log', '--reverse', '--format=%H|%h|%an|%s|%P',
        f'{prev_commit}..{source}'
    ])

    if not log_output:
        # If we skipped merges, advance past them
        advance_to = prev_commit if skipped_merges else None
        return NextCommitsInfo([], False, advance_to)

    all_commits = parse_log_output(log_output, has_parents=True)
    commits = [c for c in all_commits if not dbs.commit_get(c.hash)]

    return NextCommitsInfo(commits, False, None)


def get_next_commits(dbs, source):
    """Get the next set of commits to cherry-pick from a source

    Finds commits between the last cherry-picked commit and the next merge
    commit on the first-parent (mainline) chain of the source branch.
    Skips merges whose commits are already tracked in the database (from
    pending MRs). Decomposes mega-merges (merges containing sub-merges)
    into individual sub-merge batches.

    Args:
        dbs (Database): Database instance
        source (str): Source branch name

    Returns:
        tuple: (NextCommitsInfo, error_msg) where error_msg is None
            on success
    """
    # Get the last cherry-picked commit from database
    last_commit = dbs.source_get(source)

    if not last_commit:
        return None, f"Source '{source}' not found in database"

    # Get all first-parent commits to find merges
    fp_output = run_git([
        'log', '--reverse', '--first-parent', '--format=%H|%h|%an|%s|%P',
        f'{last_commit}..{source}'
    ])

    if not fp_output:
        return NextCommitsInfo([], False, None), None

    # Build list of merge hashes on the first-parent chain
    merge_hashes = []
    for line in fp_output.split('\n'):
        if not line:
            continue
        parts = line.split('|')
        parents = parts[-1].split()
        if len(parents) > 1:
            merge_hashes.append(parts[0])

    return find_unprocessed_commits(
        dbs, last_commit, source, merge_hashes), None


def get_commits_for_pick(commit_spec):
    """Get commits to cherry-pick from a commit specification

    Supports two formats:
    - Commit range: 'hash1..hash2' returns all commits in that range
    - Merge commit: Returns all non-merge commits that were part of the merge

    Args:
        commit_spec (str): Either 'hash1..hash2' for a range, or a single
            hash (which if it's a merge, gets all its child commits)

    Returns:
        tuple: (list of CommitInfo, error_message) - error_message is None
            on success
    """
    commits = None
    err = None

    if '..' in commit_spec:
        # Commit range format: hash1..hash2
        try:
            log_output = run_git([
                'log', '--reverse', '--format=%H|%h|%an|%s',
                commit_spec
            ])
            if log_output:
                commits = parse_log_output(log_output)
            else:
                commits, err = [], f"No commits found in range: {commit_spec}"
        except Exception:  # pylint: disable=broad-except
            err = f"Invalid commit range: {commit_spec}"
    else:
        # Single commit - check if it's a merge
        try:
            parents = run_git(['rev-parse', f'{commit_spec}^@'])
            parent_list = parents.strip().split('\n') if parents.strip() else []

            if len(parent_list) < 2:
                # Not a merge - return just this commit
                log_output = run_git(['log', '-1', '--format=%H|%h|%an|%s',
                                      commit_spec])
                commits = parse_log_output(log_output)
            else:
                # It's a merge - get commits from the merged branch
                # parent_list[0] is main branch, parent_list[1] is merged branch
                log_output = run_git([
                    'log', '--reverse', '--format=%H|%h|%an|%s',
                    f'^{parent_list[0]}', parent_list[1]
                ])
                if log_output:
                    commits = parse_log_output(log_output)
                else:
                    commits = []
                    err = f"No commits found in merge: {commit_spec}"
        except Exception:  # pylint: disable=broad-except
            err = f"Invalid commit: {commit_spec}"

    return commits, err


def detect_sub_merges(merge_hash):
    """Check if a merge commit contains sub-merges

    Examines the second parent's first-parent chain to find merge commits
    (sub-merges) within a larger merge.

    Args:
        merge_hash (str): Hash of the merge commit to check

    Returns:
        list: List of sub-merge hashes in chronological order, or empty
            list if not a merge or has no sub-merges
    """
    # Get parents of the merge
    try:
        parents = run_git(['rev-parse', f'{merge_hash}^@'])
    except command.CommandExc:
        return []

    parent_list = parents.strip().split('\n')
    if len(parent_list) < 2:
        return []

    first_parent = parent_list[0]
    second_parent = parent_list[1]

    # Find merges on the second parent's first-parent chain
    try:
        out = run_git([
            'log', '--reverse', '--first-parent', '--merges',
            '--format=%H', f'^{first_parent}', second_parent
        ])
    except command.CommandExc:
        return []

    if not out:
        return []

    return [line for line in out.split('\n') if line]


def _mega_preadd(dbs, merge_hash):
    """Pre-add a mega-merge commit to the database as 'skipped'.

    This prevents the mega-merge from appearing as an orphan commit.
    Does nothing if the commit already exists in the database.
    """
    if dbs.commit_get(merge_hash):
        return

    source_id = None
    sources = dbs.source_get_all()
    if sources:
        source_id = dbs.source_get_id(sources[0][0])
    if source_id:
        info = run_git(['log', '-1', '--format=%s|%an', merge_hash])
        parts = info.split('|', 1)
        subject = parts[0]
        author = parts[1] if len(parts) > 1 else ''
        dbs.commit_add(merge_hash, source_id, subject, author,
                       status='skipped')
        dbs.commit()


def _mega_get_batch(dbs, exclude_ref, include_ref):
    """Fetch a batch of unprocessed commits between two refs.

    Runs git log for the range ^exclude_ref include_ref, parses the
    output and filters out commits already in the database.

    Returns:
        list: CommitInfo tuples for unprocessed commits, may be empty
    """
    log_output = run_git([
        'log', '--reverse', '--format=%H|%h|%an|%s|%P',
        f'^{exclude_ref}', include_ref
    ])
    if not log_output:
        return []

    all_commits = parse_log_output(log_output, has_parents=True)
    return [c for c in all_commits if not dbs.commit_get(c.hash)]


def decompose_mega_merge(dbs, prev_commit, merge_hash, sub_merges):
    """Return the next unprocessed batch from a mega-merge

    Handles three phases:
    1. Mainline commits before the merge (prev_commit..merge^1)
    2. Sub-merge batches (one at a time, skipping processed ones)
    3. Remainder commits after the last sub-merge

    Pre-adds the mega-merge commit itself to DB as 'skipped' so it does
    not appear as an orphan commit.

    Args:
        dbs (Database): Database instance
        prev_commit (str): Hash of the last processed commit
        merge_hash (str): Hash of the mega-merge commit
        sub_merges (list): List of sub-merge hashes in chronological order

    Returns:
        tuple: (commits, advance_to) where:
            commits: list of CommitInfo tuples for the next batch
            advance_to: hash to advance source to, or None to stay put
    """
    parents = run_git(['rev-parse', f'{merge_hash}^@']).strip().split('\n')
    first_parent = parents[0]
    second_parent = parents[1]

    _mega_preadd(dbs, merge_hash)

    # Phase 1: mainline commits before the merge
    commits = _mega_get_batch(dbs, prev_commit, first_parent)
    if commits:
        return commits, first_parent

    # Phase 2: sub-merge batches
    prev_sub = first_parent
    for sub_hash in sub_merges:
        commits = _mega_get_batch(dbs, prev_sub, sub_hash)
        if commits:
            return commits, None
        prev_sub = sub_hash

    # Phase 3: remainder after the last sub-merge
    last_sub = sub_merges[-1] if sub_merges else first_parent
    commits = _mega_get_batch(dbs, last_sub, second_parent)
    if commits:
        return commits, None

    return [], None


def do_next_set(args, dbs):
    """Show the next set of commits to cherry-pick from a source

    Args:
        args (Namespace): Parsed arguments with 'source' attribute
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 if source not found
    """
    source = args.source
    info, err = get_next_commits(dbs, source)

    if err:
        tout.error(err)
        return 1

    if not info.commits:
        tout.info('No new commits to cherry-pick')
        return 0

    if info.merge_found:
        tout.info(f'Next set from {source} '
                  f'({len(info.commits)} commits):')
    else:
        tout.info(f'Remaining commits from {source} '
                  f'({len(info.commits)} commits, no merge found):')

    for commit in info.commits:
        tout.info(f'  {commit.chash} {commit.subject}')

    return 0


def _next_fetch_merges(last_commit, source, count):
    """Fetch the next merge commits from a source.

    Returns:
        list: (hash, short_hash, subject) tuples, up to count entries
    """
    out = run_git([
        'log', '--reverse', '--first-parent', '--merges',
        '--format=%H|%h|%s',
        f'{last_commit}..{source}'
    ])

    if not out:
        return []

    merges = []
    for line in out.split('\n'):
        if not line:
            continue
        parts = line.split('|', 2)
        commit_hash = parts[0]
        chash = parts[1]
        subject = parts[2] if len(parts) > 2 else ''
        merges.append((commit_hash, chash, subject))
        if len(merges) >= count:
            break

    return merges


def _next_build_display(merges):
    """Build display list, expanding mega-merges into sub-merges.

    Each entry is (chash, subject, is_mega, sub_list) where sub_list
    is a list of (chash, subject) for mega-merge sub-merges.

    Returns:
        tuple: (display_list, total_sub_count)
    """
    display = []
    total_sub = 0
    for commit_hash, chash, subject in merges:
        sub_merges = detect_sub_merges(commit_hash)
        if sub_merges:
            sub_list = []
            for sub_hash in sub_merges:
                try:
                    info = run_git(
                        ['log', '-1', '--format=%h|%s', sub_hash])
                    parts = info.strip().split('|', 1)
                    sub_chash = parts[0]
                    sub_subject = parts[1] if len(parts) > 1 else ''
                except Exception:  # pylint: disable=broad-except
                    sub_chash = sub_hash[:11]
                    sub_subject = '(unknown)'
                sub_list.append((sub_chash, sub_subject))
            display.append((chash, subject, True, sub_list))
            total_sub += len(sub_list)
        else:
            display.append((chash, subject, False, None))

    return display, total_sub


def _next_show_merges(source, merges, display, total_sub):
    """Display the next-merges listing."""
    n_items = total_sub + len(merges) - len(
        [d for d in display if d[2]])
    tout.info(f'Next merges from {source} '
              f'({n_items} from {len(merges)} first-parent):')
    idx = 1
    for chash, subject, is_mega, sub_list in display:
        if is_mega:
            tout.info(f'  {chash} {subject} '
                      f'({len(sub_list)} sub-merges):')
            for sub_chash, sub_subject in sub_list:
                tout.info(f'    {idx}. {sub_chash} {sub_subject}')
                idx += 1
        else:
            tout.info(f'  {idx}. {chash} {subject}')
            idx += 1


def do_next_merges(args, dbs):
    """Show the next N merges to be applied from a source

    Args:
        args (Namespace): Parsed arguments with 'source' and 'count' attributes
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 if source not found
    """
    source = args.source
    count = args.count

    last_commit = dbs.source_get(source)
    if not last_commit:
        tout.error(f"Source '{source}' not found in database")
        return 1

    merges = _next_fetch_merges(last_commit, source, count)
    if not merges:
        tout.info('No merges remaining')
        return 0

    display, total_sub = _next_build_display(merges)

    _next_show_merges(source, merges, display, total_sub)

    return 0


def do_count_merges(args, dbs):
    """Count total remaining merges to be applied from a source

    Args:
        args (Namespace): Parsed arguments with 'source' attribute
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 if source not found
    """
    source = args.source

    # Get the last cherry-picked commit from database
    last_commit = dbs.source_get(source)

    if not last_commit:
        tout.error(f"Source '{source}' not found in database")
        return 1

    # Count merge commits on the first-parent chain
    fp_output = run_git([
        'log', '--first-parent', '--merges', '--oneline',
        f'{last_commit}..{source}'
    ])

    if not fp_output:
        tout.info('0 merges remaining')
        return 0

    count = len([line for line in fp_output.split('\n') if line])
    tout.info(f'{count} merges remaining from {source}')

    return 0


HISTORY_FILE = '.pickman-history'

# Tag added to MR title when skipped
SKIPPED_TAG = '[skipped]'


def parse_instruction(body):
    """Parse a pickman instruction from a comment body

    Recognizes instructions in these formats:
    - pickman <instruction>
    - pickman: <instruction>
    - @pickman <instruction>
    - @pickman: <instruction>

    Args:
        body (str): Comment body text

    Returns:
        str: The instruction (e.g., 'skip', 'unskip'), or None if not found
    """
    # Pattern matches: optional @, 'pickman', optional colon, then the command
    pattern = r'@?pickman:?\s+(\w+)'
    match = re.search(pattern, body.lower())
    if match:
        return match.group(1)
    return None


def has_instruction(body, instruction):
    """Check if a comment body contains a specific pickman instruction

    Args:
        body (str): Comment body text
        instruction (str): Instruction to check for (e.g., 'skip', 'unskip')

    Returns:
        bool: True if the comment contains the specified instruction
    """
    return parse_instruction(body) == instruction


def handle_unskip_comments(remote, mr_iid, title, unresolved, dbs):
    """Handle unskip comments on an MR

    Args:
        remote (str): Remote name
        mr_iid (int): Merge request IID
        title (str): Current MR title
        unresolved (list): List of unresolved comments
        dbs (Database): Database instance

    Returns:
        tuple: (handled, new_unresolved) where handled is True if unskip was
            processed and new_unresolved is the filtered comment list
    """
    unskip_comments = [c for c in unresolved
                       if has_instruction(c.body, 'unskip')]
    if not unskip_comments:
        return False, unresolved

    tout.info(f'MR !{mr_iid} has unskip request')

    # Update MR title to remove [skipped] tag
    if SKIPPED_TAG in title:
        new_title = title.replace(f'{SKIPPED_TAG} ', '')
        new_title = new_title.replace(SKIPPED_TAG, '')
        gitlab_api.update_mr_title(remote, mr_iid, new_title)
        tout.info(f'MR !{mr_iid} unskipped, will resume processing')

    # Mark unskip comments as processed
    for comment in unskip_comments:
        dbs.comment_mark_processed(mr_iid, comment.id)
    dbs.commit()

    # Reply to confirm the unskip
    gitlab_api.reply_to_mr(
        remote, mr_iid,
        'MR unskipped. Processing will resume on next poll.'
    )

    # Remove unskip comments from unresolved list for further processing
    new_unresolved = [c for c in unresolved
                      if not has_instruction(c.body, 'unskip')]
    return True, new_unresolved


def handle_skip_comments(remote, mr_iid, title, unresolved, dbs):
    """Handle skip comments on an MR

    Args:
        remote (str): Remote name
        mr_iid (int): Merge request IID
        title (str): Current MR title
        unresolved (list): List of unresolved comments
        dbs (Database): Database instance

    Returns:
        bool: True if skip was processed
    """
    skip_comments = [c for c in unresolved
                     if has_instruction(c.body, 'skip')]
    if not skip_comments:
        return False

    tout.info(f'MR !{mr_iid} has skip request, marking as skipped')

    # Update MR title to add [skipped] tag
    if SKIPPED_TAG not in title:
        new_title = f'{SKIPPED_TAG} {title}'
        gitlab_api.update_mr_title(remote, mr_iid, new_title)

    # Mark skip comments as processed
    for comment in skip_comments:
        dbs.comment_mark_processed(mr_iid, comment.id)
    dbs.commit()

    # Reply to confirm the skip
    gitlab_api.reply_to_mr(
        remote, mr_iid,
        'MR marked as skipped. Use `pickman unskip` or manually '
        'remove [skipped] from the title to resume processing.'
    )
    return True


def format_history(source, commits, branch_name):
    """Format a summary of the cherry-pick operation

    Args:
        source (str): Source branch name
        commits (list): list of CommitInfo tuples
        branch_name (str): Name of the cherry-pick branch

    Returns:
        str: Formatted summary text
    """
    commit_list = '\n'.join(
        f'- {c.chash} {c.subject}'
        for c in commits
    )

    return f"""## {date.today()}: {source}

Branch: {branch_name}

Commits:
{commit_list}"""


def get_history(fname, source, commits, branch_name, conv_log):
    """Read, update and write history file for a cherry-pick operation

    Args:
        fname (str): History filename to read/write
        source (str): Source branch name
        commits (list): list of CommitInfo tuples
        branch_name (str): Name of the cherry-pick branch
        conv_log (str): The agent's conversation output

    Returns:
        tuple: (content, commit_msg) where content is the updated history
            and commit_msg is the git commit message
    """
    summary = format_history(source, commits, branch_name)
    entry = f"""{summary}

### Conversation log
{conv_log}

---

"""

    # Read existing content
    existing = ''
    if os.path.exists(fname):
        existing = tools.read_file(fname, binary=False)
        # Remove existing entry for this branch (from ## header to ---)
        pattern = rf'## [^\n]+\n\nBranch: {re.escape(branch_name)}\n.*?---\n\n'
        existing = re.sub(pattern, '', existing, flags=re.DOTALL)

    content = existing + entry

    # Write updated history file
    tools.write_file(fname, content, binary=False)

    # Generate commit message
    commit_msg = (f'pickman: Record cherry-pick of {len(commits)} commits '
                  f'from {source}\n\n')
    commit_msg += '\n'.join(f'- {c.chash} {c.subject}' for c in commits)

    return content, commit_msg


def write_history(source, commits, branch_name, conv_log):
    """Write an entry to the pickman history file and commit it

    Args:
        source (str): Source branch name
        commits (list): list of CommitInfo tuples
        branch_name (str): Name of the cherry-pick branch
        conv_log (str): The agent's conversation output
    """
    _, commit_msg = get_history(HISTORY_FILE, source, commits, branch_name,
                                conv_log)

    # Commit the history file (use -f in case .gitignore patterns match)
    run_git(['add', '-f', HISTORY_FILE])
    run_git(['commit', '-m', commit_msg])

    tout.info(f'Updated {HISTORY_FILE}')


def push_mr(args, branch_name, title, description):
    """Push branch and create merge request

    Args:
        args (Namespace): Parsed arguments with 'remote' and 'target'
        branch_name (str): Branch name to push
        title (str): MR title
        description (str): MR description

    Returns:
        bool: True on success, False on failure
    """
    mr_url = gitlab_api.push_and_create_mr(
        args.remote, branch_name, args.target, title, description
    )
    return bool(mr_url)


def _is_merge_in_progress():
    """Check if a git merge is currently in progress.

    Returns:
        bool: True if MERGE_HEAD exists (merge in progress), False otherwise
    """
    try:
        run_git(['rev-parse', '--verify', 'MERGE_HEAD'])
        return True
    except Exception:  # pylint: disable=broad-except
        return False


def _subtree_run_update(name, tag):
    """Run update-subtree.sh to pull a subtree update.

    On failure, checks whether a merge is in progress (indicating
    conflicts). If so, returns SUBTREE_CONFLICT with the merge state
    intact so the caller can invoke the agent. Otherwise aborts any
    in-progress merge and returns SUBTREE_FAIL.

    Returns:
        int: SUBTREE_OK on success, SUBTREE_CONFLICT on merge conflicts,
            SUBTREE_FAIL on other failures
    """
    try:
        result = command.run_one(
            './tools/update-subtree.sh', 'pull', name, tag,
            capture=True, raise_on_error=False)
        if result.stdout:
            tout.info(result.stdout)
        if result.return_code:
            tout.error(f'Subtree update failed (exit code '
                        f'{result.return_code})')
            if result.stderr:
                tout.error(result.stderr)
            if _is_merge_in_progress():
                return SUBTREE_CONFLICT
            try:
                run_git(['merge', '--abort'])
            except Exception:  # pylint: disable=broad-except
                pass
            return SUBTREE_FAIL
    except Exception as exc:  # pylint: disable=broad-except
        tout.error(f'Subtree update failed: {exc}')
        return SUBTREE_FAIL

    return SUBTREE_OK


def _subtree_record(dbs, source, squash_hash, merge_hash):
    """Mark subtree commits as applied and advance the source position."""
    source_id = dbs.source_get_id(source)
    for commit_hash in [squash_hash, merge_hash]:
        if not dbs.commit_get(commit_hash):
            info = run_git(['log', '-1', '--format=%s|%an', commit_hash])
            parts = info.split('|', 1)
            subj = parts[0]
            auth = parts[1] if len(parts) > 1 else ''
            dbs.commit_add(commit_hash, source_id, subj, auth,
                           status='applied')
    dbs.commit()

    dbs.source_set(source, merge_hash)
    dbs.commit()
    tout.info(f"Advanced source '{source}' past subtree merge "
              f'{merge_hash[:12]}')


def apply_subtree_update(dbs, source, name, tag, merge_hash, remote,  # pylint: disable=too-many-arguments
                         target, push=True):
    """Apply a subtree update on the target branch

    Runs tools/update-subtree.sh to pull the subtree update, then
    optionally pushes the result to the remote target branch.

    Args:
        dbs (Database): Database instance
        source (str): Source branch name
        name (str): Subtree name ('dts', 'mbedtls', 'lwip')
        tag (str): Tag to pull (e.g. 'v6.15-dts')
        merge_hash (str): Hash of the subtree merge commit to advance past
        remote (str): Git remote name (e.g. 'ci')
        target (str): Target branch name (e.g. 'master')
        push (bool): Whether to push the result to the remote

    Returns:
        int: 0 on success, 1 on failure
    """
    tout.info(f'Applying subtree update: {name} -> {tag}')

    # Get the squash commit (second parent of the merge)
    parents = run_git(['rev-parse', f'{merge_hash}^@']).split()
    if len(parents) < 2:
        tout.error(f'Subtree merge {merge_hash[:12]} has no second parent')
        return 1
    squash_hash = parents[1]

    try:
        run_git(['checkout', target])
    except command.CommandExc:
        # Bare name may be ambiguous when multiple remotes have it
        try:
            run_git(['checkout', '-b', target,
                     f'{remote}/{target}'])
        except command.CommandExc:
            tout.error(f'Could not checkout {target}')
            return 1

    ret = _subtree_run_update(name, tag)
    if ret == SUBTREE_FAIL:
        return 1
    if ret == SUBTREE_CONFLICT:
        # Resolve via reverse lookup of subtree path
        subtree_path = next(
            (p for p, n in SUBTREE_NAMES.items() if n == name), None)
        tout.info('Merge conflicts detected, invoking agent...')
        success, _ = agent.resolve_subtree_conflicts(
            name, tag, subtree_path)
        if not success:
            tout.error('Agent could not resolve subtree conflicts')
            try:
                run_git(['merge', '--abort'])
            except command.CommandExc:
                pass
            return 1

    if push:
        try:
            gitlab_api.push_branch(remote, target, skip_ci=True)
            tout.info(f'Pushed {target} to {remote}')
        except command.CommandExc as exc:
            tout.error(f'Failed to push {target}: {exc}')
            return 1

    _subtree_record(dbs, source, squash_hash, merge_hash)

    return 0


def _prepare_get_commits(dbs, source, remote, target):
    """Get the next commits to apply, handling subtrees and skips.

    Fetch the next batch of commits from the source. If a subtree
    update is encountered, apply it and retry. If all commits in a
    merge are already processed, advance the source and retry.

    Args:
        dbs (Database): Database instance
        source (str): Source branch name
        remote (str): Git remote name (e.g. 'ci'), or None to skip
            subtree updates
        target (str): Target branch name (e.g. 'master')

    Returns:
        tuple: (NextCommitsInfo, return_code) where return_code is None
            on success, or an int (0 or 1) if there is nothing to do
    """
    while True:
        info, err = get_next_commits(dbs, source)
        if err:
            tout.error(err)
            return None, 1

        if info.subtree_update:
            name, tag = info.subtree_update
            tout.info(f'Subtree update needed: {name} -> {tag}')
            if not remote:
                tout.error('Cannot apply subtree update without remote')
                return None, 1
            ret = apply_subtree_update(dbs, source, name, tag,
                                       info.advance_to, remote,
                                       target)
            if ret:
                return None, ret
            continue

        if not info.commits:
            if info.advance_to:
                dbs.source_set(source, info.advance_to)
                dbs.commit()
                tout.info(f"Advanced source '{source}' to "
                          f'{info.advance_to[:12]}')
                continue
            tout.info('No new commits to cherry-pick')
            return None, 0

        return info, None


def prepare_apply(dbs, source, branch, remote=None, target=None,  # pylint: disable=too-many-arguments
                   info=None):
    """Prepare for applying commits from a source branch

    Get the next commits, set up the branch name and prints info about
    what will be applied. When a subtree update is encountered, apply it
    automatically and retry.

    Args:
        dbs (Database): Database instance
        source (str): Source branch name
        branch (str): Branch name to use, or None to auto-generate
        remote (str): Git remote name (e.g. 'ci'), or None to skip
            subtree updates
        target (str): Target branch name (e.g. 'master')
        info (NextCommitsInfo): Pre-fetched commit info from
            _prepare_get_commits(), or None to fetch it here

    Returns:
        tuple: (ApplyInfo, return_code) where ApplyInfo is set if there are
            commits to apply, or None with return_code indicating the result
            (0 for no commits, 1 for error)
    """
    if info is None:
        info, ret = _prepare_get_commits(dbs, source, remote, target)
        if ret is not None:
            return None, ret

    commits = info.commits

    # Save current branch to return to later
    original_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD'])

    # Generate branch name if not provided
    branch_name = branch
    if not branch_name:
        # Use first commit's short hash as part of branch name
        branch_name = f'cherry-{commits[0].chash}'

    # Delete branch if it already exists
    if run_git(['branch', '--list', branch_name]).strip():
        tout.info(f'Deleting existing branch {branch_name}')
        run_git(['branch', '-D', branch_name])

    if info.merge_found:
        tout.info(f'Applying next set from {source} ({len(commits)} commits):')
    else:
        tout.info(f'Applying remaining commits from {source} '
                  f'({len(commits)} commits, no merge found):')

    tout.info(f'  Branch: {branch_name}')
    for commit in commits:
        tout.info(f'  {commit.chash} {commit.subject}')
    tout.info('')

    return ApplyInfo(commits, branch_name, original_branch,
                     info.merge_found, info.advance_to), 0


# pylint: disable=too-many-arguments
def _applied_advance_source(dbs, source, commits, advance_to,
                            signal_commit):
    """Advance the source position after skipping already-applied commits.

    Chooses the new position from advance_to, signal_commit, or the
    last commit hash, in that priority order.
    """
    if advance_to is not None:
        new_hash = advance_to
    elif signal_commit:
        new_hash = signal_commit
    else:
        new_hash = commits[-1].hash

    dbs.source_set(source, new_hash)
    dbs.commit()
    tout.info(f"Updated source '{source}' to {new_hash[:12]}")


def _applied_create_skip_mr(args, source, commits, branch_name, conv_log):
    """Push a skip branch and create an MR recording the skip.

    Returns:
        int: 0 on success, 1 on failure
    """
    remote = args.remote
    target = args.target

    try:
        run_git(['checkout', '-b', branch_name, f'{remote}/{target}'])
    except Exception:  # pylint: disable=broad-except
        # Branch may already exist from failed attempt
        try:
            run_git(['checkout', branch_name])
        except Exception:  # pylint: disable=broad-except
            tout.error(f'Could not create/checkout branch {branch_name}')
            return 1

    title = f'{SKIPPED_TAG} [pickman] {commits[-1].subject}'
    summary = format_history(source, commits, branch_name)
    description = (f'{summary}\n\n'
                   f'**Status:** Commits already applied to {target} '
                   f'with different hashes.\n\n'
                   f'### Conversation log\n{conv_log}')

    mr_url = gitlab_api.push_and_create_mr(
        remote, branch_name, target, title, description
    )
    if not mr_url:
        return 1

    return 0


def handle_already_applied(dbs, source, commits, branch_name, conv_log, args,
                           signal_commit, advance_to=None):
    """Handle the case where commits are already applied to the target branch

    Creates an MR with [skip] prefix to record the attempt and updates the
    source position in the database.

    Args:
        dbs (Database): Database instance
        source (str): Source branch name
        commits (list): List of CommitInfo namedtuples
        branch_name (str): Branch name that was attempted
        conv_log (str): Conversation log from the agent
        args (Namespace): Parsed arguments with 'push', 'remote', 'target'
        signal_commit (str): Last commit hash from signal file
        advance_to (str): Hash to advance source to, or None to use last
            commit. If explicitly None (sub-merge batch), source is not
            advanced.

    Returns:
        int: 0 on success, 1 on failure
    """
    tout.info('Commits already applied to target branch - creating skip MR')

    for commit in commits:
        dbs.commit_set_status(commit.hash, 'skipped')
    dbs.commit()

    _applied_advance_source(dbs, source, commits, advance_to,
                            signal_commit)

    if args.push:
        return _applied_create_skip_mr(args, source, commits,
                                       branch_name, conv_log)

    return 0


def execute_apply(dbs, source, commits, branch_name, args, advance_to=None):  # pylint: disable=too-many-locals
    """Execute the apply operation: run agent, update database, push MR

    Args:
        dbs (Database): Database instance
        source (str): Source branch name
        commits (list): List of CommitInfo namedtuples
        branch_name (str): Branch name for cherry-picks
        args (Namespace): Parsed arguments with 'push', 'remote', 'target'
        advance_to (str): Hash to advance source to after success, or None
            to skip source advancement (sub-merge batch)

    Returns:
        tuple: (ret, success, conv_log) where ret is 0 on success,
            1 on failure
    """
    # Check for already applied commits before proceeding
    applied_map = build_applied_map(commits)

    # Add all commits to database with 'pending' status (agent updates later)
    source_id = dbs.source_get_id(source)
    for commit in commits:
        dbs.commit_add(commit.hash, source_id, commit.subject, commit.author,
                       status='pending')
    dbs.commit()

    # Convert CommitInfo to AgentCommit format expected by agent
    agent_commits = [AgentCommit(c.hash, c.chash, c.subject,
                                 applied_map.get(c.hash)) for c in commits]
    success, conv_log = agent.cherry_pick_commits(agent_commits, source,
                                                  branch_name)

    # Check for signal file from agent
    signal_status, signal_commit = agent.read_signal_file()
    if signal_status == agent.SIGNAL_APPLIED:
        ret = handle_already_applied(dbs, source, commits, branch_name,
                                     conv_log, args, signal_commit,
                                     advance_to)
        return ret, False, conv_log

    # Verify the branch actually exists - agent may have aborted and deleted it
    if success:
        try:
            exists = run_git(['branch', '--list', branch_name]).strip()
        except Exception:  # pylint: disable=broad-except
            exists = ''
        if not exists:
            tout.warning(f'Branch {branch_name} does not exist - '
                         'agent may have aborted')
            success = False

    # Update commit status based on result
    status = 'applied' if success else 'conflict'
    for commit in commits:
        dbs.commit_set_status(commit.hash, status)
    dbs.commit()

    ret = 0 if success else 1

    if success:
        # Push and create MR if requested
        if args.push:
            title = f'[pickman] {commits[-1].subject}'
            summary = format_history(source, commits, branch_name)
            description = f'{summary}\n\n### Conversation log\n{conv_log}'
            if not push_mr(args, branch_name, title, description):
                ret = 1
        else:
            tout.info(f"Use 'pickman commit-source {source} "
                      f"{commits[-1].chash}' to update the database")

    # Update database with the last processed commit if successful
    if success and advance_to is not None:
        dbs.source_set(source, advance_to)
        dbs.commit()

    return ret, success, conv_log


def do_apply(args, dbs, info=None):
    """Apply the next set of commits using Claude agent

    Args:
        args (Namespace): Parsed arguments with 'source' and 'branch' attributes
        dbs (Database): Database instance
        info (NextCommitsInfo): Pre-fetched commit info from
            _prepare_get_commits(), or None to fetch during prepare

    Returns:
        int: 0 on success, 1 on failure
    """
    source = args.source
    info, ret = prepare_apply(dbs, source, args.branch, args.remote,
                              args.target, info=info)
    if not info:
        return ret

    commits = info.commits
    branch_name = info.branch_name
    original_branch = info.original_branch

    ret, success, conv_log = execute_apply(dbs, source, commits,
                                           branch_name, args,
                                           info.advance_to)

    # Write history file if successful
    if success:
        write_history(source, commits, branch_name, conv_log)

    # Return to original branch
    current_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD'])
    if current_branch != original_branch:
        tout.info(f'Returning to {original_branch}')
        run_git(['checkout', original_branch])

    return ret


def do_pick(args, dbs):  # pylint: disable=unused-argument,too-many-locals
    """Cherry-pick commits ad-hoc using Claude agent

    This allows cherry-picking a commit range or merge commit children without
    tracking in the database. Useful for one-off cherry-picks.

    Args:
        args (Namespace): Parsed arguments with 'commits', 'branch', etc.
        dbs (Database): Database instance (unused for ad-hoc picks)

    Returns:
        int: 0 on success, 1 on failure
    """
    commit_spec = args.commits

    # Get commits to cherry-pick
    commits, err = get_commits_for_pick(commit_spec)
    if err:
        tout.error(err)
        return 1

    if not commits:
        tout.info('No commits to cherry-pick')
        return 0

    # Save current branch to return to later
    original_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD'])

    # Generate branch name if not provided
    branch_name = args.branch
    if not branch_name:
        branch_name = f'pick-{commits[0].chash}'

    # Delete branch if it already exists
    if run_git(['branch', '--list', branch_name]).strip():
        tout.info(f'Deleting existing branch {branch_name}')
        run_git(['branch', '-D', branch_name])

    tout.info(f'Cherry-picking {len(commits)} commit(s):')
    tout.info(f'  Branch: {branch_name}')
    for commit in commits:
        tout.info(f'  {commit.chash} {commit.subject}')
    tout.info('')

    # Convert CommitInfo to AgentCommit format (no applied_as for ad-hoc)
    agent_commits = [AgentCommit(c.hash, c.chash, c.subject, None)
                     for c in commits]

    # Run the agent to cherry-pick
    success, conv_log = agent.cherry_pick_commits(agent_commits, 'ad-hoc',
                                                  branch_name)

    # Verify the branch actually exists - agent may have aborted and deleted it
    if success:
        try:
            exists = run_git(['branch', '--list', branch_name]).strip()
        except Exception:  # pylint: disable=broad-except
            exists = ''
        if not exists:
            tout.warning(f'Branch {branch_name} does not exist - '
                         'agent may have aborted')
            success = False

    ret = 0 if success else 1

    if success and args.push:
        title = f'[pick] {commits[-1].subject}'
        commit_list = '\n'.join(f'- {c.chash} {c.subject}' for c in commits)
        description = (f'Ad-hoc cherry-pick of {len(commits)} commit(s)\n\n'
                       f'### Commits\n{commit_list}\n\n'
                       f'### Conversation log\n{conv_log}')
        if not push_mr(args, branch_name, title, description):
            ret = 1
    elif success:
        tout.info(f'Commits cherry-picked to branch {branch_name}')

    # Return to original branch
    current_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD'])
    if current_branch != original_branch:
        tout.info(f'Returning to {original_branch}')
        run_git(['checkout', original_branch])

    return ret


def do_push_branch(args, dbs):  # pylint: disable=unused-argument
    """Push a branch using the GitLab API token for authentication

    This allows pushing as the token owner (e.g., a bot account) rather than
    using the user's configured git credentials. Useful for ensuring all
    pickman commits come from the same account.

    Args:
        args (Namespace): Parsed arguments with 'remote', 'branch', 'force',
            'run_ci'
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 on failure
    """
    skip_ci = not args.run_ci
    try:
        gitlab_api.push_branch(args.remote, args.branch, args.force,
                               skip_ci=skip_ci)
    except command.CommandExc:
        return 1
    return 0


def do_commit_source(args, dbs):
    """Update the database with the last cherry-picked commit

    Args:
        args (Namespace): Parsed arguments with 'source' and 'commit' attributes
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 on failure
    """
    source = args.source
    commit = args.commit

    # Resolve the commit to a full hash
    try:
        full_hash = run_git(['rev-parse', commit])
    except Exception:  # pylint: disable=broad-except
        tout.error(f"Could not resolve commit '{commit}'")
        return 1

    old_commit = dbs.source_get(source)
    if not old_commit:
        tout.error(f"Source '{source}' not found in database")
        return 1

    dbs.source_set(source, full_hash)
    dbs.commit()

    short_old = old_commit[:12]
    short_new = full_hash[:12]
    tout.info(f"Updated '{source}': {short_old} -> {short_new}")

    return 0


def _rewind_fetch_merges(current, count):
    """Fetch first-parent merges and find target index.

    Returns:
        tuple: (merges, target_idx) where merges is a list of
            (hash, short_hash, subject) tuples, or None on error
    """
    try:
        out = run_git([
            'log', '--first-parent', '--merges', '--format=%H|%h|%s',
            f'-{count + 1}', current
        ])
    except Exception:  # pylint: disable=broad-except
        tout.error(f'Could not read merge history for {current[:12]}')
        return None

    if not out:
        tout.error('No merges found in history')
        return None

    merges = []
    for line in out.strip().split('\n'):
        if not line:
            continue
        parts = line.split('|', 2)
        merges.append((parts[0], parts[1],
                        parts[2] if len(parts) > 2 else ''))

    if len(merges) < 2:
        tout.error(f'Not enough merges to rewind by {count}')
        return None

    target_idx = min(count, len(merges) - 1)
    return merges, target_idx


def _rewind_get_range_commits(dbs, target_hash, current):
    """Get commits in range and filter to those in database.

    Returns:
        tuple: (range_hashes_str, db_commits_list) or None on error
    """
    try:
        range_hashes = run_git([
            'rev-list', f'{target_hash}..{current}'
        ])
    except Exception:  # pylint: disable=broad-except
        tout.error(f'Could not list commits in range '
                   f'{target_hash[:12]}..{current[:12]}')
        return None

    db_commits = []
    if range_hashes:
        for chash in range_hashes.strip().split('\n'):
            if chash and dbs.commit_get(chash):
                db_commits.append(chash)

    return range_hashes, db_commits


def _rewind_find_branches(range_hashes, remote):
    """Find cherry-pick branches matching commits in the range.

    Returns:
        list: Branch names (without remote prefix) that match
    """
    if not range_hashes:
        return []

    hash_set = set(range_hashes.strip().split('\n'))
    try:
        branch_out = run_git(
            ['branch', '-r', '--list', f'{remote}/cherry-*'])
    except Exception:  # pylint: disable=broad-except
        branch_out = ''

    mr_branches = []
    remote_prefix = f'{remote}/'
    for line in branch_out.strip().split('\n'):
        branch = line.strip()
        if not branch:
            continue
        # Branch is like 'ci/cherry-abc1234'; extract the hash part
        short = branch.removeprefix(f'{remote_prefix}cherry-')
        # Check if any commit in the range starts with this hash
        for chash in hash_set:
            if chash.startswith(short):
                mr_branches.append(
                    branch.removeprefix(remote_prefix))
                break

    return mr_branches


def _rewind_find_mrs(mr_branches, remote):
    """Look up MR details for matching branches.

    Returns:
        list: PickmanMr objects whose source_branch matches
    """
    if not mr_branches:
        return []

    matched_mrs = []
    mrs = gitlab_api.get_open_pickman_mrs(remote)
    if mrs:
        branch_set = set(mr_branches)
        for merge_req in mrs:
            if merge_req.source_branch in branch_set:
                matched_mrs.append(merge_req)

    return matched_mrs


def _rewind_show_summary(source, current, merges, target_idx,
                         db_commits, matched_mrs, mr_branches,
                         force):
    """Display rewind summary."""
    current_short = current[:12]
    target_chash = merges[target_idx][1]
    target_subject = merges[target_idx][2]

    prefix = '' if force else '[dry run] '
    tout.info(f"{prefix}Rewind '{source}': "
              f'{current_short} -> {target_chash}')
    tout.info(f'  Target: {target_chash} {target_subject}')
    tout.info('  Merges being rewound:')
    for i in range(target_idx):
        tout.info(f'    {merges[i][1]} {merges[i][2]}')
    tout.info(f'  Commits to delete from database: {len(db_commits)}')

    if matched_mrs:
        tout.info('  MRs to delete on GitLab:')
        for merge_req in matched_mrs:
            tout.info(f'    !{merge_req.iid}: {merge_req.title}')
            tout.info(f'      {merge_req.web_url}')
    elif mr_branches:
        tout.info('  Branches to check for MRs:')
        for branch in mr_branches:
            tout.info(f'    {branch}')


def do_rewind(args, dbs):
    """Rewind the source position back by N merges

    By default performs a dry run, showing what would happen. Use --force
    to actually execute the rewind.

    Walks back N merges on the first-parent chain from the current source
    position, deletes the commits in that range from the database, and
    resets the source to the earlier position.

    Args:
        args (Namespace): Parsed arguments with 'source', 'count', 'force'
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 on failure
    """
    source = args.source
    count = args.count
    force = args.force

    current = dbs.source_get(source)
    if not current:
        tout.error(f"Source '{source}' not found in database")
        return 1

    result = _rewind_fetch_merges(current, count)
    if not result:
        return 1
    merges, target_idx = result
    target_hash = merges[target_idx][0]

    result = _rewind_get_range_commits(dbs, target_hash, current)
    if not result:
        return 1
    range_hashes, db_commits = result

    mr_branches = _rewind_find_branches(range_hashes, args.remote)
    matched_mrs = _rewind_find_mrs(mr_branches, args.remote)

    _rewind_show_summary(source, current, merges, target_idx,
                         db_commits, matched_mrs, mr_branches, force)

    if not force:
        tout.info('Use --force to execute this rewind')
        return 0

    for chash in db_commits:
        dbs.commit_delete(chash)

    dbs.source_set(source, target_hash)
    dbs.commit()

    tout.info(f'  Deleted {len(db_commits)} commit(s) from database')

    return 0


# pylint: disable=too-many-locals,too-many-branches,too-many-statements
def process_single_mr(remote, merge_req, dbs, target):
    """Process review comments on a single MR

    Args:
        remote (str): Remote name
        merge_req (PickmanMr): MR object from get_open_pickman_mrs()
        dbs (Database): Database instance for tracking processed comments
        target (str): Target branch for rebase operations

    Returns:
        int: 1 if MR was processed, 0 otherwise
    """
    mr_iid = merge_req.iid
    comments = gitlab_api.get_mr_comments(remote, mr_iid)
    if comments is None:
        comments = []

    # Filter to unresolved comments that haven't been processed
    unresolved = []
    for com in comments:
        if com.resolved:
            continue
        if dbs.comment_is_processed(mr_iid, com.id):
            continue
        unresolved.append(com)

    # Check for unskip comments first (takes precedence)
    handled, unresolved = handle_unskip_comments(
        remote, mr_iid, merge_req.title, unresolved, dbs)
    processed = 1 if handled else 0

    # Check for skip comments
    if handle_skip_comments(remote, mr_iid, merge_req.title, unresolved, dbs):
        return processed + 1

    # If MR is currently skipped, don't process rebases or other comments
    if SKIPPED_TAG in merge_req.title:
        return processed

    # Check if rebase is needed
    needs_rebase = merge_req.needs_rebase or merge_req.has_conflicts

    # Skip if no comments and no rebase needed
    if not unresolved and not needs_rebase:
        return processed

    tout.info('')
    if needs_rebase:
        if merge_req.has_conflicts:
            tout.info(f"MR !{mr_iid} has merge conflicts - rebasing...")
        else:
            tout.info(f"MR !{mr_iid} needs rebase...")
    if unresolved:
        tout.info(f"MR !{mr_iid} has {len(unresolved)} new comment(s):")
        for comment in unresolved:
            tout.info(f'  [{comment.author}]: {comment.body[:80]}...')

    # Run agent to handle comments and/or rebase
    success, conversation_log = agent.handle_mr_comments(
        mr_iid,
        merge_req.source_branch,
        unresolved,
        remote,
        target,
        needs_rebase=needs_rebase,
        has_conflicts=merge_req.has_conflicts,
        mr_description=merge_req.description,
    )

    if success:
        # Mark comments as processed
        for comment in unresolved:
            dbs.comment_mark_processed(mr_iid, comment.id)
        dbs.commit()

        # Update MR description with comments and conversation log
        old_desc = merge_req.description
        comment_summary = '\n'.join(
            f"- [{c.author}]: {c.body}"
            for c in unresolved
        )
        new_desc = (f"{old_desc}\n\n### Review response\n\n"
                    f"**Comments addressed:**\n{comment_summary}\n\n"
                    f"**Response:**\n{conversation_log}")
        gitlab_api.update_mr_desc(remote, mr_iid, new_desc)

        # Update .pickman-history
        update_history(merge_req.source_branch,
                                   unresolved, conversation_log)

        tout.info(f'Updated MR !{mr_iid} description and history')
    else:
        tout.error(f"Failed to handle comments for MR !{mr_iid}")

    return processed + 1


def process_mr_reviews(remote, mrs, dbs, target='master'):
    """Process review comments on open MRs

    Checks each MR for unresolved comments and uses Claude agent to address
    them. Updates MR description and .pickman-history with conversation log.

    Args:
        remote (str): Remote name
        mrs (list): List of MR dicts from get_open_pickman_mrs()
        dbs (Database): Database instance for tracking processed comments
        target (str): Target branch for rebase operations

    Returns:
        int: Number of MRs with comments processed
    """
    # Save current branch to restore later
    original_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD'])

    # Fetch to get latest remote state (needed for rebase)
    tout.info(f'Fetching {remote}...')
    run_git(['fetch', remote])

    processed = 0
    for merge_req in mrs:
        processed += process_single_mr(remote, merge_req, dbs, target)

    # Restore original branch
    if processed:
        tout.info(f'Returning to {original_branch}')
        run_git(['checkout', original_branch])

    return processed


def _rebase_mr_branch(remote, merge_req, dbs, target):
    """Rebase an MR branch onto the target before attempting a pipeline fix

    When a branch needs rebasing, the pipeline failure may be caused by the
    stale base rather than by the cherry-picked commits. Rebasing and pushing
    triggers a fresh pipeline run.

    Args:
        remote (str): Remote name
        merge_req (PickmanMr): MR with a failed pipeline
        dbs (Database): Database instance for tracking fix attempts
        target (str): Target branch

    Returns:
        True if the branch was rebased and pushed, False if the rebase
        failed (conflicts), or None if no rebase is needed
    """
    if not merge_req.needs_rebase and not merge_req.has_conflicts:
        return None

    mr_iid = merge_req.iid
    branch = merge_req.source_branch
    if merge_req.has_conflicts:
        tout.info(f'MR !{mr_iid}: has conflicts, rebasing before '
                  f'pipeline fix...')
    else:
        tout.info(f'MR !{mr_iid}: needs rebase, rebasing before '
                  f'pipeline fix...')
    run_git(['checkout', branch])
    try:
        run_git(['rebase', f'{remote}/{target}'])
    except command.CommandExc:
        tout.warning(f'MR !{mr_iid}: rebase failed, aborting')
        try:
            run_git(['rebase', '--abort'])
        except command.CommandExc:
            pass
        return False
    gitlab_api.push_branch(remote, branch, force=True, skip_ci=False)
    dbs.pfix_add(mr_iid, merge_req.pipeline_id, 0, 'rebased')
    dbs.commit()
    tout.info(f'MR !{mr_iid}: rebased and pushed, waiting for '
              f'new pipeline')
    return True


def _attempt_pipeline_fix(remote, merge_req, dbs, target, attempt):
    """Run the agent to fix a failed pipeline and report the result

    Fetches the failed-job logs, invokes the fix agent, then pushes the
    result and updates the MR description and history on success, or posts
    a failure comment otherwise.

    Args:
        remote (str): Remote name
        merge_req (PickmanMr): MR with a failed pipeline
        dbs (Database): Database instance for tracking fix attempts
        target (str): Target branch
        attempt (int): Current fix attempt number

    Returns:
        bool: True if the fix was attempted, False if no failed jobs
            were found
    """
    mr_iid = merge_req.iid

    # Fetch failed jobs
    failed_jobs = gitlab_api.get_failed_jobs(remote, merge_req.pipeline_id)
    if not failed_jobs:
        tout.info(f'MR !{mr_iid}: no failed jobs found')
        dbs.pfix_add(mr_iid, merge_req.pipeline_id, attempt, 'no_jobs')
        dbs.commit()
        return False

    # Run agent to fix the failures
    success, conversation_log = agent.fix_pipeline(
        mr_iid,
        merge_req.source_branch,
        failed_jobs,
        remote,
        target,
        mr_description=merge_req.description,
        attempt=attempt,
    )

    status = 'success' if success else 'failure'
    dbs.pfix_add(mr_iid, merge_req.pipeline_id, attempt, status)
    dbs.commit()

    if success:
        # Push the fix branch to the original MR branch
        branch = merge_req.source_branch
        gitlab_api.push_branch(remote, branch, force=True,
                               skip_ci=False)

        # Update MR description with fix log
        old_desc = merge_req.description
        job_names = ', '.join(j.name for j in failed_jobs)
        new_desc = (f"{old_desc}\n\n### Pipeline fix (attempt {attempt})"
                    f"\n\n**Failed jobs:** {job_names}\n\n"
                    f"**Response:**\n{conversation_log}")
        gitlab_api.update_mr_desc(remote, mr_iid, new_desc)

        # Post a comment summarising the fix
        gitlab_api.reply_to_mr(
            remote, mr_iid,
            f'Pipeline fix (attempt {attempt}): '
            f'fixed failed job(s) {job_names}.\n\n'
            f'{conversation_log[:2000]}')

        # Update .pickman-history
        update_history_pipeline_fix(merge_req.source_branch, failed_jobs,
                                    conversation_log, attempt)

        tout.info(f'MR !{mr_iid}: pipeline fix pushed (attempt {attempt})')
    else:
        gitlab_api.reply_to_mr(
            remote, mr_iid,
            f'Pipeline fix attempt {attempt} failed. '
            f'Agent output:\n\n{conversation_log[:1000]}')
        tout.error(f'MR !{mr_iid}: pipeline fix failed '
                   f'(attempt {attempt})')

    return True


def process_pipeline_failures(remote, mrs, dbs, target, max_retries):
    """Process pipeline failures on open MRs

    Checks each MR for failed pipelines and uses Claude agent to diagnose
    and fix them. Tracks attempts in the database to avoid reprocessing.

    Args:
        remote (str): Remote name
        mrs (list): List of active (non-skipped) PickmanMr tuples
        dbs (Database): Database instance for tracking fix attempts
        target (str): Target branch
        max_retries (int): Maximum fix attempts per MR

    Returns:
        int: Number of MRs with pipeline fixes attempted
    """
    # Save current branch to restore later
    original_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD'])

    # Fetch to get latest remote state
    tout.info(f'Fetching {remote}...')
    run_git(['fetch', remote])

    processed = 0
    for merge_req in mrs:
        mr_iid = merge_req.iid

        # Skip if pipeline is not failed or has no pipeline
        if merge_req.pipeline_status != 'failed':
            continue
        if merge_req.pipeline_id is None:
            continue

        # Skip if this pipeline was already handled
        if dbs.pfix_has(mr_iid, merge_req.pipeline_id):
            continue

        rebased = _rebase_mr_branch(remote, merge_req, dbs, target)
        if rebased is not None:
            if rebased:
                processed += 1
            continue

        attempt = dbs.pfix_count(mr_iid) + 1

        # Check retry limit
        if attempt > max_retries:
            tout.info(f'MR !{mr_iid}: reached fix retry limit '
                      f'({max_retries}), skipping')
            gitlab_api.reply_to_mr(
                remote, mr_iid,
                f'Pipeline fix: reached retry limit ({max_retries} '
                f'attempts). Manual intervention required.')
            dbs.pfix_add(mr_iid, merge_req.pipeline_id, attempt, 'skipped')
            dbs.commit()
            continue

        tout.info('')
        tout.info(f'MR !{mr_iid}: pipeline {merge_req.pipeline_id} failed, '
                  f'attempting fix (attempt {attempt}/{max_retries})...')

        if _attempt_pipeline_fix(remote, merge_req, dbs, target, attempt):
            processed += 1

    # Restore original branch
    if processed:
        tout.info(f'Returning to {original_branch}')
        run_git(['checkout', original_branch])

    return processed


def update_history_pipeline_fix(branch_name, failed_jobs, conversation_log,
                                attempt):
    """Append pipeline fix handling to .pickman-history

    Args:
        branch_name (str): Branch name for the MR
        failed_jobs (list): List of FailedJob tuples that were fixed
        conversation_log (str): Agent conversation log
        attempt (int): Fix attempt number
    """
    job_summary = '\n'.join(
        f'- {j.name} ({j.stage})'
        for j in failed_jobs
    )

    entry = f'''### Pipeline fix: {date.today()} (attempt {attempt})

Branch: {branch_name}

Failed jobs:
{job_summary}

### Conversation log
{conversation_log}

---

'''

    # Append to history file
    existing = ''
    if os.path.exists(HISTORY_FILE):
        with open(HISTORY_FILE, 'r', encoding='utf-8') as fhandle:
            existing = fhandle.read()

    with open(HISTORY_FILE, 'w', encoding='utf-8') as fhandle:
        fhandle.write(existing + entry)

    # Commit the history file
    run_git(['add', '-f', HISTORY_FILE])
    run_git(['commit', '-m',
             f'pickman: Record pipeline fix for {branch_name}'])


def update_history(branch_name, comments, conversation_log):
    """Append review handling to .pickman-history

    Args:
        branch_name (str): Branch name for the MR
        comments (list): List of comments that were addressed
        conversation_log (str): Agent conversation log
    """
    comment_summary = '\n'.join(
        f'- [{c.author}]: {c.body[:100]}...'
        for c in comments
    )

    entry = f'''### Review: {date.today()}

Branch: {branch_name}

Comments addressed:
{comment_summary}

### Conversation log
{conversation_log}

---

'''

    # Append to history file
    existing = ''
    if os.path.exists(HISTORY_FILE):
        existing = tools.read_file(HISTORY_FILE, binary=False)

    tools.write_file(HISTORY_FILE, existing + entry, binary=False)

    # Commit the history file
    run_git(['add', '-f', HISTORY_FILE])
    run_git(['commit', '-m',
             f'pickman: Record review handling for {branch_name}'])


def do_review(args, dbs):
    """Check open pickman MRs and handle comments

    Lists open MRs created by pickman, checks for human comments, and uses
    Claude agent to address them.

    Args:
        args (Namespace): Parsed arguments with 'remote' attribute
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 on failure
    """
    remote = args.remote

    # Get open pickman MRs
    mrs = gitlab_api.get_open_pickman_mrs(remote)
    if mrs is None:
        return 1

    if not mrs:
        tout.info('No open pickman MRs found')
        return 0

    tout.info(f'Found {len(mrs)} open pickman MR(s):')
    for merge_req in mrs:
        tout.info(f"  !{merge_req.iid}: {merge_req.title}")

    process_mr_reviews(remote, mrs, dbs)

    return 0


def parse_mr_description(desc):
    """Parse a pickman MR description to extract source and last commit

    Args:
        desc (str): MR description text

    Returns:
        tuple: (source_branch, last_commit_hash) or (None, None)
            if not parseable
    """
    # Extract source branch from "## date: source_branch" line
    source_match = re.search(r'^## [^:]+: (.+)$', desc, re.MULTILINE)
    if not source_match:
        return None, None
    source = source_match.group(1)

    # Extract commits from '- hash subject' lines (must be at least 7 chars)
    commit_matches = re.findall(r'^- ([a-f0-9]{7,}) ', desc, re.MULTILINE)
    if not commit_matches:
        return None, None

    # Last commit is the last one in the list
    last_hash = commit_matches[-1]

    return source, last_hash


def process_merged_mrs(remote, source, dbs):
    """Check for merged MRs and update the database

    Args:
        remote (str): Remote name
        source (str): Source branch name to filter by
        dbs (Database): Database instance

    Returns:
        int: Number of MRs processed, or -1 on error
    """
    merged_mrs = gitlab_api.get_merged_pickman_mrs(remote)
    if merged_mrs is None:
        return -1

    processed = 0
    for merge_req in merged_mrs:
        mr_source, last_hash = parse_mr_description(merge_req.description)

        # Only process MRs for the requested source branch
        if mr_source != source:
            continue

        # Check if this MR's last commit is newer than current database
        current = dbs.source_get(source)
        if not current:
            continue

        # Resolve the short hash to full hash
        try:
            full_hash = run_git(['rev-parse', last_hash])
        except Exception:  # pylint: disable=broad-except
            tout.warning(f"Could not resolve commit '{last_hash}' from "
                         f"MR !{merge_req.iid}")
            continue

        # Skip if already at this position
        if full_hash == current:
            continue

        # Check if this commit is newer than current (current is ancestor of it)
        try:
            # Is current an ancestor of last_hash? (meaning last_hash is newer)
            run_git(['merge-base', '--is-ancestor', current, full_hash])
        except Exception:  # pylint: disable=broad-except
            continue  # current is not an ancestor, so last_hash is not newer

        # Update database
        short_old = current[:12]
        short_new = full_hash[:12]
        tout.info(f"MR !{merge_req.iid} merged, updating '{source}': "
                  f'{short_old} -> {short_new}')
        dbs.source_set(source, full_hash)
        dbs.commit()
        processed += 1

    return processed


def do_step(args, dbs):
    """Create an MR if below the max allowed

    Checks for merged pickman MRs and updates the database, then checks for
    open pickman MRs. If open MRs exist, processes any review comments. If
    the number of open MRs is below max_mrs, runs apply with push to create
    a new one.

    Args:
        args (Namespace): Parsed arguments with 'source', 'remote', 'target',
            'max_mrs'
        dbs (Database): Database instance

    Returns:
        int: 0 on success, 1 on failure
    """
    try:
        return _do_step(args, dbs)
    except requests.exceptions.ConnectionError as exc:
        tout.error(f'step failed with connection error: {exc}')
        return 1


def _do_step(args, dbs):
    """Internal implementation of do_step"""
    remote = args.remote
    source = args.source

    # First check for merged MRs and update database
    processed = process_merged_mrs(remote, source, dbs)
    if processed < 0:
        return 1

    # Check for open pickman MRs
    mrs = gitlab_api.get_open_pickman_mrs(remote)
    if mrs is None:
        return 1

    # Separate skipped and active MRs
    active_mrs = [m for m in mrs if SKIPPED_TAG not in m.title]
    skipped_mrs = [m for m in mrs if SKIPPED_TAG in m.title]

    if mrs:
        if active_mrs:
            tout.info(f'Found {len(active_mrs)} open pickman MR(s):')
            for merge_req in active_mrs:
                tout.info(f"  !{merge_req.iid}: {merge_req.title}")
        if skipped_mrs:
            tout.info(f'Found {len(skipped_mrs)} skipped pickman MR(s):')
            for merge_req in skipped_mrs:
                tout.info(f"  !{merge_req.iid}: {merge_req.title}")

        # Process any review comments on all open MRs (including skipped,
        # in case they have an unskip request)
        process_mr_reviews(remote, mrs, dbs, args.target)

        # Process pipeline failures on active MRs only
        if active_mrs and args.fix_retries > 0:
            process_pipeline_failures(remote, active_mrs, dbs,
                                      args.target, args.fix_retries)

    # Process subtree updates and advance past fully-processed merges
    # regardless of MR count, since these don't create MRs
    info, ret = _prepare_get_commits(dbs, source, remote, args.target)
    if ret is not None:
        if ret:
            return ret
        return 0

    # Only block new MR creation if we've reached the max allowed open MRs
    max_mrs = args.max_mrs
    if len(active_mrs) >= max_mrs:
        tout.info('')
        tout.info(f'Already have {len(active_mrs)} open MR(s) (max: {max_mrs})')
        return 0

    # No pending MRs, run apply with push
    # First fetch to get latest remote state
    tout.info(f'Fetching {remote}...')
    run_git(['fetch', remote])

    if active_mrs:
        tout.info('Creating another MR...')
    else:
        tout.info('No pending pickman MRs, creating new one...')
    args.push = True
    args.branch = None  # Let do_apply generate branch name
    return do_apply(args, dbs, info=info)


def do_poll(args, dbs):
    """Run step repeatedly until stopped

    Runs the step command in a loop with a configurable interval. Useful for
    automated workflows that continuously process cherry-picks.

    Args:
        args (Namespace): Parsed arguments with 'source', 'interval', 'remote',
            'target'
        dbs (Database): Database instance

    Returns:
        int: 0 on success (never returns unless interrupted)
    """
    interval = args.interval
    tout.info(f'Polling every {interval} seconds (Ctrl+C to stop)...')
    tout.info('')

    while True:
        try:
            ret = do_step(args, dbs)
            if ret != 0:
                tout.warning(f'step returned {ret}')
            tout.info('')
            tout.info(f'Sleeping {interval} seconds...')
            time.sleep(interval)
            tout.info('')
        except KeyboardInterrupt:
            tout.info('')
            tout.info('Polling stopped by user')
            return 0


def do_test(args, dbs):  # pylint: disable=unused-argument
    """Run tests for this module.

    Args:
        args (Namespace): Parsed arguments
        dbs (Database): Database instance

    Returns:
        int: 0 if tests passed, 1 otherwise
    """
    loader = unittest.TestLoader()
    suite = loader.loadTestsFromModule(ftest)
    runner = unittest.TextTestRunner()
    result = runner.run(suite)

    return 0 if result.wasSuccessful() else 1


# Command dispatch table
COMMANDS = {
    'add-source': do_add_source,
    'apply': do_apply,
    'check': do_check,
    'check-gitlab': do_check_gitlab,
    'commit-source': do_commit_source,
    'compare': do_compare,
    'count-merges': do_count_merges,
    'list-sources': do_list_sources,
    'next-merges': do_next_merges,
    'next-set': do_next_set,
    'pick': do_pick,
    'poll': do_poll,
    'push-branch': do_push_branch,
    'review': do_review,
    'rewind': do_rewind,
    'step': do_step,
    'test': do_test,
}


def do_pickman(args):
    """Main entry point for pickman commands.

    Args:
        args (Namespace): Parsed arguments

    Returns:
        int: 0 on success, 1 on failure
    """
    tout.init(tout.INFO)

    handler = COMMANDS.get(args.cmd)
    if handler:
        dbs = database.Database(DB_FNAME)
        dbs.start()
        try:
            return handler(args, dbs)
        finally:
            dbs.close()
    return 1